Merge branch 'main' into config-merge

Carlos Alexandro Becker created

Change summary

.github/cla-signatures.json                                                                                                                             |   56 
.github/workflows/lint.yml                                                                                                                              |    2 
.github/workflows/release.yml                                                                                                                           |    1 
.github/workflows/security.yml                                                                                                                          |   12 
.gitignore                                                                                                                                              |    1 
AGENTS.md                                                                                                                                               |    7 
LICENSE.md                                                                                                                                              |    2 
README.md                                                                                                                                               |   11 
Taskfile.yaml                                                                                                                                           |   31 
go.mod                                                                                                                                                  |   38 
go.sum                                                                                                                                                  |   68 
internal/agent/agentic_fetch_tool.go                                                                                                                    |    2 
internal/agent/common_test.go                                                                                                                           |    8 
internal/agent/coordinator.go                                                                                                                           |   30 
internal/agent/hyper/provider.go                                                                                                                        |   19 
internal/agent/hyper/provider.json                                                                                                                      |    0 
internal/agent/tools/diagnostics.go                                                                                                                     |   38 
internal/agent/tools/edit.go                                                                                                                            |    7 
internal/agent/tools/grep.go                                                                                                                            |   44 
internal/agent/tools/list_mcp_resources.go                                                                                                              |  104 
internal/agent/tools/list_mcp_resources.md                                                                                                              |   18 
internal/agent/tools/lsp_restart.go                                                                                                                     |    9 
internal/agent/tools/mcp-tools.go                                                                                                                       |    7 
internal/agent/tools/mcp/init.go                                                                                                                        |  101 
internal/agent/tools/mcp/init_test.go                                                                                                                   |   38 
internal/agent/tools/mcp/prompts.go                                                                                                                     |    7 
internal/agent/tools/mcp/resources.go                                                                                                                   |   96 
internal/agent/tools/mcp/tools.go                                                                                                                       |   17 
internal/agent/tools/multiedit.go                                                                                                                       |    7 
internal/agent/tools/read_mcp_resource.go                                                                                                               |  102 
internal/agent/tools/read_mcp_resource.md                                                                                                               |   20 
internal/agent/tools/references.go                                                                                                                      |   13 
internal/agent/tools/search.go                                                                                                                          |    4 
internal/agent/tools/view.go                                                                                                                            |    7 
internal/agent/tools/write.go                                                                                                                           |    7 
internal/app/app.go                                                                                                                                     |   80 
internal/app/app_test.go                                                                                                                                |  157 
internal/app/lsp.go                                                                                                                                     |  163 
internal/cmd/dirs_test.go                                                                                                                               |    2 
internal/cmd/login.go                                                                                                                                   |   10 
internal/cmd/logs.go                                                                                                                                    |    4 
internal/cmd/root.go                                                                                                                                    |   49 
internal/cmd/root_test.go                                                                                                                               |  160 
internal/commands/commands.go                                                                                                                           |    4 
internal/config/agent_id_test.go                                                                                                                        |   29 
internal/config/config.go                                                                                                                               |   68 
internal/config/init.go                                                                                                                                 |   25 
internal/config/load.go                                                                                                                                 |   13 
internal/config/load_test.go                                                                                                                            |   12 
internal/csync/value_test.go                                                                                                                            |    6 
internal/db/connect.go                                                                                                                                  |   10 
internal/db/connect_modernc.go                                                                                                                          |   11 
internal/db/connect_ncruces.go                                                                                                                          |   20 
internal/db/db.go                                                                                                                                       |   10 
internal/db/querier.go                                                                                                                                  |    1 
internal/db/read_files.sql.go                                                                                                                           |   33 
internal/db/sql/read_files.sql                                                                                                                          |    5 
internal/event/event.go                                                                                                                                 |    5 
internal/filetracker/service.go                                                                                                                         |   23 
internal/format/spinner.go                                                                                                                              |   13 
internal/fsext/paste.go                                                                                                                                 |   36 
internal/fsext/paste_test.go                                                                                                                            |   12 
internal/lsp/client.go                                                                                                                                  |  186 
internal/lsp/client_test.go                                                                                                                             |    2 
internal/lsp/filtermatching_test.go                                                                                                                     |  111 
internal/lsp/handlers.go                                                                                                                                |    6 
internal/lsp/manager.go                                                                                                                                 |  312 
internal/projects/projects_test.go                                                                                                                      |    6 
internal/shell/background.go                                                                                                                            |   32 
internal/shell/background_test.go                                                                                                                       |   43 
internal/shell/shell.go                                                                                                                                 |    4 
internal/tui/components/anim/anim.go                                                                                                                    |  447 
internal/tui/components/chat/chat.go                                                                                                                    |  782 
internal/tui/components/chat/editor/clipboard.go                                                                                                        |    8 
internal/tui/components/chat/editor/editor.go                                                                                                           |  780 
internal/tui/components/chat/editor/keys.go                                                                                                             |   77 
internal/tui/components/chat/header/header.go                                                                                                           |  160 
internal/tui/components/chat/messages/messages.go                                                                                                       |  461 
internal/tui/components/chat/messages/renderer.go                                                                                                       | 1403 
internal/tui/components/chat/messages/tool.go                                                                                                           |  877 
internal/tui/components/chat/sidebar/sidebar.go                                                                                                         |  608 
internal/tui/components/chat/splash/keys.go                                                                                                             |   58 
internal/tui/components/chat/splash/splash.go                                                                                                           |  874 
internal/tui/components/chat/todos/todos.go                                                                                                             |   67 
internal/tui/components/completions/completions.go                                                                                                      |  308 
internal/tui/components/completions/keys.go                                                                                                             |   72 
internal/tui/components/core/core.go                                                                                                                    |  207 
internal/tui/components/core/layout/layout.go                                                                                                           |   27 
internal/tui/components/core/status/status.go                                                                                                           |  113 
internal/tui/components/core/status_test.go                                                                                                             |  144 
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden                                                                       |    1 
internal/tui/components/core/testdata/TestStatus/Default.golden                                                                                         |    1 
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden                                                                                |    1 
internal/tui/components/core/testdata/TestStatus/LongDescription.golden                                                                                 |    1 
internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden                                                                                     |    1 
internal/tui/components/core/testdata/TestStatus/NoIcon.golden                                                                                          |    1 
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden                                                                                 |    1 
internal/tui/components/core/testdata/TestStatus/WithColors.golden                                                                                      |    1 
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden                                                                                  |    1 
internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden                                                                                |    1 
internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden                                                                               |    1 
internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden                                                                               |    1 
internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden                                                                               |    1 
internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden                                                                               |    1 
internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden                                                                               |    1 
internal/tui/components/dialogs/commands/arguments.go                                                                                                   |  245 
internal/tui/components/dialogs/commands/commands.go                                                                                                    |  479 
internal/tui/components/dialogs/commands/keys.go                                                                                                        |  133 
internal/tui/components/dialogs/copilot/device_flow.go                                                                                                  |  281 
internal/tui/components/dialogs/dialogs.go                                                                                                              |  165 
internal/tui/components/dialogs/filepicker/filepicker.go                                                                                                |  260 
internal/tui/components/dialogs/filepicker/keys.go                                                                                                      |   80 
internal/tui/components/dialogs/hyper/device_flow.go                                                                                                    |  267 
internal/tui/components/dialogs/keys.go                                                                                                                 |   43 
internal/tui/components/dialogs/models/apikey.go                                                                                                        |  203 
internal/tui/components/dialogs/models/keys.go                                                                                                          |  120 
internal/tui/components/dialogs/models/list.go                                                                                                          |  333 
internal/tui/components/dialogs/models/list_recent_test.go                                                                                              |  369 
internal/tui/components/dialogs/models/models.go                                                                                                        |  549 
internal/tui/components/dialogs/permissions/keys.go                                                                                                     |  113 
internal/tui/components/dialogs/permissions/permissions.go                                                                                              |  899 
internal/tui/components/dialogs/quit/keys.go                                                                                                            |   75 
internal/tui/components/dialogs/quit/quit.go                                                                                                            |  120 
internal/tui/components/dialogs/reasoning/reasoning.go                                                                                                  |  264 
internal/tui/components/dialogs/sessions/keys.go                                                                                                        |   67 
internal/tui/components/dialogs/sessions/sessions.go                                                                                                    |  181 
internal/tui/components/files/files.go                                                                                                                  |  146 
internal/tui/components/image/image.go                                                                                                                  |   86 
internal/tui/components/image/load.go                                                                                                                   |  169 
internal/tui/components/logo/logo.go                                                                                                                    |  346 
internal/tui/components/logo/rand.go                                                                                                                    |   24 
internal/tui/components/lsp/lsp.go                                                                                                                      |  144 
internal/tui/components/mcp/mcp.go                                                                                                                      |  138 
internal/tui/exp/list/filterable.go                                                                                                                     |  329 
internal/tui/exp/list/filterable_group.go                                                                                                               |  315 
internal/tui/exp/list/filterable_test.go                                                                                                                |   68 
internal/tui/exp/list/grouped.go                                                                                                                        |  100 
internal/tui/exp/list/items.go                                                                                                                          |  399 
internal/tui/exp/list/keys.go                                                                                                                           |   76 
internal/tui/exp/list/list.go                                                                                                                           | 1775 
internal/tui/exp/list/list_test.go                                                                                                                      |  653 
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden                                                           |   10 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden                                                              |   10 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden                                                    |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden                                       |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden              |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden    |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden                             |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden                                                |   20 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden                                      |   20 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden                                                                        |   10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden                                                                 |   10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden                                                                          |   10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden                                                                 |   10 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden  |   10 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden      |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden              |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden           |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden     |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden     |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden   |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden   |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden   |   10 
internal/tui/highlight/highlight.go                                                                                                                     |   54 
internal/tui/keys.go                                                                                                                                    |   45 
internal/tui/page/chat/chat.go                                                                                                                          | 1407 
internal/tui/page/chat/keys.go                                                                                                                          |   53 
internal/tui/page/chat/pills.go                                                                                                                         |  125 
internal/tui/page/page.go                                                                                                                               |    8 
internal/tui/styles/charmtone.go                                                                                                                        |   83 
internal/tui/styles/chroma.go                                                                                                                           |   79 
internal/tui/styles/icons.go                                                                                                                            |   48 
internal/tui/styles/markdown.go                                                                                                                         |  205 
internal/tui/styles/theme.go                                                                                                                            |  709 
internal/tui/tui.go                                                                                                                                     |  712 
internal/tui/util/shell.go                                                                                                                              |   15 
internal/tui/util/util.go                                                                                                                               |   45 
internal/ui/AGENTS.md                                                                                                                                   |   20 
internal/ui/chat/agent.go                                                                                                                               |    2 
internal/ui/chat/lsp_restart.go                                                                                                                         |    2 
internal/ui/chat/messages.go                                                                                                                            |    8 
internal/ui/chat/tools.go                                                                                                                               |   10 
internal/ui/common/capabilities.go                                                                                                                      |    3 
internal/ui/common/common.go                                                                                                                            |    4 
internal/ui/common/diff.go                                                                                                                              |    2 
internal/ui/common/elements.go                                                                                                                          |    4 
internal/ui/completions/completions.go                                                                                                                  |  130 
internal/ui/completions/item.go                                                                                                                         |    8 
internal/ui/dialog/actions.go                                                                                                                           |   21 
internal/ui/dialog/api_key_input.go                                                                                                                     |   10 
internal/ui/dialog/arguments.go                                                                                                                         |    6 
internal/ui/dialog/common.go                                                                                                                            |    5 
internal/ui/dialog/models.go                                                                                                                            |   91 
internal/ui/dialog/oauth.go                                                                                                                             |   10 
internal/ui/dialog/sessions.go                                                                                                                          |    8 
internal/ui/diffview/Taskfile.yaml                                                                                                                      |    0 
internal/ui/diffview/chroma.go                                                                                                                          |    0 
internal/ui/diffview/diffview.go                                                                                                                        |    0 
internal/ui/diffview/diffview_test.go                                                                                                                   |    2 
internal/ui/diffview/split.go                                                                                                                           |    0 
internal/ui/diffview/style.go                                                                                                                           |    0 
internal/ui/diffview/testdata/TestDefault.after                                                                                                         |    0 
internal/ui/diffview/testdata/TestDefault.before                                                                                                        |    0 
internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden                                                                     |    0 
internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden                                                                                |    0 
internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden                                                                          |    0 
internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden                                                                         |    0 
internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden                                                                                |    0 
internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden                                                                          |    0 
internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden                                                                         |    0 
internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden                                                                     |    0 
internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden                                                                   |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden                                                                  |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden                                                                           |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden                                                                          |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden                                                                        |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden                                                                       |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden                                                                        |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden                                                                       |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden                                                                   |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden                                                                           |    0 
internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden                                                                          |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden                                                                                   |    0 
internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden                                                                                             |    0 
internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden                                                                                           |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden                                                                                 |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden                                                                               |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden                                                                              |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden                                                                            |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden                                                                      |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden                                                                    |    0 
internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden                                                                    |    0 
internal/ui/diffview/testdata/TestLineBreakIssue.after                                                                                                  |    0 
internal/ui/diffview/testdata/TestLineBreakIssue.before                                                                                                 |    0 
internal/ui/diffview/testdata/TestMultipleHunks.after                                                                                                   |    0 
internal/ui/diffview/testdata/TestMultipleHunks.before                                                                                                  |    0 
internal/ui/diffview/testdata/TestNarrow.after                                                                                                          |    0 
internal/ui/diffview/testdata/TestNarrow.before                                                                                                         |    0 
internal/ui/diffview/testdata/TestTabs.after                                                                                                            |    0 
internal/ui/diffview/testdata/TestTabs.before                                                                                                           |    0 
internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden                                                                |    0 
internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden                                                                   |    0 
internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden                                                         |    0 
internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden                                                            |    0 
internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden                                                         |    0 
internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden                                                            |    0 
internal/ui/diffview/testdata/TestUdiff/Unified.golden                                                                                                  |    0 
internal/ui/diffview/udiff_test.go                                                                                                                      |    0 
internal/ui/diffview/util.go                                                                                                                            |    0 
internal/ui/diffview/util_test.go                                                                                                                       |    0 
internal/ui/image/image.go                                                                                                                              |   13 
internal/ui/image/image_test.go                                                                                                                         |   46 
internal/ui/list/highlight.go                                                                                                                           |    2 
internal/ui/list/list.go                                                                                                                                |   26 
internal/ui/logo/logo.go                                                                                                                                |   15 
internal/ui/model/chat.go                                                                                                                               |   21 
internal/ui/model/clipboard.go                                                                                                                          |   15 
internal/ui/model/clipboard_not_supported.go                                                                                                            |    2 
internal/ui/model/clipboard_supported.go                                                                                                                |    2 
internal/ui/model/filter.go                                                                                                                             |   22 
internal/ui/model/header.go                                                                                                                             |   74 
internal/ui/model/keys.go                                                                                                                               |    5 
internal/ui/model/landing.go                                                                                                                            |    4 
internal/ui/model/lsp.go                                                                                                                                |   17 
internal/ui/model/mcp.go                                                                                                                                |    7 
internal/ui/model/onboarding.go                                                                                                                         |    6 
internal/ui/model/session.go                                                                                                                            |  218 
internal/ui/model/sidebar.go                                                                                                                            |    7 
internal/ui/model/status.go                                                                                                                             |   20 
internal/ui/model/ui.go                                                                                                                                 |  428 
internal/ui/styles/styles.go                                                                                                                            |   13 
internal/ui/util/util.go                                                                                                                                |    6 
internal/uicmd/uicmd.go                                                                                                                                 |  314 
schema.json                                                                                                                                             |   10 
604 files changed, 2,414 insertions(+), 23,182 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -1175,6 +1175,62 @@
       "created_at": "2026-02-02T19:27:08Z",
       "repoId": 987670088,
       "pullRequestNo": 2095
+    },
+    {
+      "name": "zhiquanchi",
+      "id": 29973289,
+      "comment_id": 3845838711,
+      "created_at": "2026-02-04T07:39:06Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2112
+    },
+    {
+      "name": "inquam",
+      "id": 1265038,
+      "comment_id": 3849304908,
+      "created_at": "2026-02-04T19:22:33Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2124
+    },
+    {
+      "name": "nickgrim",
+      "id": 8376,
+      "comment_id": 3852565144,
+      "created_at": "2026-02-05T10:17:46Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2131
+    },
+    {
+      "name": "francescoalemanno",
+      "id": 50984334,
+      "comment_id": 3858464719,
+      "created_at": "2026-02-06T07:16:50Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2142
+    },
+    {
+      "name": "biisal",
+      "id": 153633053,
+      "comment_id": 3866503536,
+      "created_at": "2026-02-08T08:15:11Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2164
+    },
+    {
+      "name": "mishudark",
+      "id": 211144,
+      "comment_id": 3866939317,
+      "created_at": "2026-02-08T10:27:09Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2165
+    },
+    {
+      "name": "portertech",
+      "id": 149630,
+      "comment_id": 3878650318,
+      "created_at": "2026-02-10T15:39:14Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2183
     }
   ]
 }

.github/workflows/lint.yml 🔗

@@ -8,5 +8,5 @@ jobs:
     uses: charmbracelet/meta/.github/workflows/lint.yml@main
     with:
       golangci_path: .golangci.yml
-      golangci_version: v2.4
+      golangci_version: v2.9
       timeout: 10m

.github/workflows/release.yml 🔗

@@ -27,7 +27,6 @@ jobs:
       fury_token: ${{ secrets.FURY_TOKEN }}
       nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}
       nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}
-      npm_token: ${{ secrets.NPM_TOKEN }}
       snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }}
       aur_key: ${{ secrets.AUR_KEY }}
       macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }}

.github/workflows/security.yml 🔗

@@ -30,11 +30,11 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           persist-credentials: false
-      - uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
+      - uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
         with:
           languages: ${{ matrix.language }}
-      - uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
-      - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
+      - uses: github/codeql-action/autobuild@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
+      - uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
 
   grype:
     runs-on: ubuntu-latest
@@ -46,13 +46,13 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           persist-credentials: false
-      - uses: anchore/scan-action@8d2fce09422cd6037e577f4130e9b925e9a37175 # v7.3.1
+      - uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c # v7.3.2
         id: scan
         with:
           path: "."
           fail-build: true
           severity-cutoff: critical
-      - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
+      - uses: github/codeql-action/upload-sarif@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
         with:
           sarif_file: ${{ steps.scan.outputs.sarif }}
 
@@ -73,7 +73,7 @@ jobs:
       - name: Run govulncheck
         run: |
           govulncheck -C . -format sarif ./... > results.sarif
-      - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
+      - uses: github/codeql-action/upload-sarif@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
         with:
           sarif_file: results.sarif
 

.gitignore 🔗

@@ -50,3 +50,4 @@ Thumbs.db
 manpages/
 completions/crush.*sh
 .prettierignore
+.task

AGENTS.md 🔗

@@ -7,17 +7,18 @@
 - **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
   - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
 - **Lint**: `task lint:fix`
-- **Format**: `task fmt` (gofumpt -w .)
+- **Format**: `task fmt` (`gofumpt -w .`)
+- **Modernize**: `task modernize` (runs `modernize` which make code simplifications)
 - **Dev**: `task dev` (runs with profiling enabled)
 
 ## Code Style Guidelines
 
-- **Imports**: Use goimports formatting, group stdlib, external, internal packages
+- **Imports**: Use `goimports` formatting, group stdlib, external, internal packages
 - **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint
 - **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported
 - **Types**: Prefer explicit types, use type aliases for clarity (e.g., `type AgentName string`)
 - **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
-- **Context**: Always pass context.Context as first parameter for operations
+- **Context**: Always pass `context.Context` as first parameter for operations
 - **Interfaces**: Define interfaces in consuming packages, keep them small and focused
 - **Structs**: Use struct embedding for composition, group related fields
 - **Constants**: Use typed constants with iota for enums, group in const blocks

LICENSE.md 🔗

@@ -6,7 +6,7 @@ FSL-1.1-MIT
 
 ## Notice
 
-Copyright 2025 Charmbracelet, Inc
+Copyright 2025-2026 Charmbracelet, Inc.
 
 ## Terms and Conditions
 

README.md 🔗

@@ -187,6 +187,7 @@ That said, you can also set environment variables for preferred providers.
 | `GEMINI_API_KEY`            | Google Gemini                                      |
 | `SYNTHETIC_API_KEY`         | Synthetic                                          |
 | `ZAI_API_KEY`               | Z.ai                                               |
+| `MINIMAX_API_KEY`           | MiniMax                                            |
 | `HF_TOKEN`                  | Hugging Face Inference                             |
 | `CEREBRAS_API_KEY`          | Cerebras                                           |
 | `OPENROUTER_API_KEY`        | OpenRouter                                         |
@@ -202,6 +203,16 @@ That said, you can also set environment variables for preferred providers.
 | `AZURE_OPENAI_API_KEY`      | Azure OpenAI models (optional when using Entra ID) |
 | `AZURE_OPENAI_API_VERSION`  | Azure OpenAI models                                |
 
+### Subscriptions
+
+If you prefer subscription-based usage, here are some plans that work well in
+Crush:
+
+- [Synthetic](https://synthetic.new/pricing)
+- [GLM Coding Plan](https://z.ai/subscribe)
+- [Kimi Code](https://www.kimi.com/membership/pricing)
+- [MiniMax Coding Plan](https://platform.minimax.io/subscribe/coding-plan)
+
 ### By the Way
 
 Is there a provider you’d like to see in Crush? Is there an existing model that needs an update?

Taskfile.yaml 🔗

@@ -46,8 +46,11 @@ tasks:
       LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
     cmds:
       - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ."
+    sources:
+      - ./**/*.go
+      - go.mod
     generates:
-      - crush
+      - crush{{exeExt}}
 
   run:
     desc: Run build
@@ -55,6 +58,24 @@ tasks:
       - task: build
       - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}"
 
+  run:catwalk:
+    desc: Run build with local Catwalk
+    env:
+      CATWALK_URL: http://localhost:8080
+    cmds:
+      - task: build
+      - ./crush {{.CLI_ARGS}}
+
+  run:onboarding:
+    desc: Run build with custom config to test onboarding
+    env:
+      CRUSH_GLOBAL_DATA: tmp/onboarding/data
+      CRUSH_GLOBAL_CONFIG: tmp/onboarding/config
+    cmds:
+      - task: build
+      - rm -rf tmp/onboarding
+      - ./crush {{.CLI_ARGS}}
+
   test:
     desc: Run tests
     cmds:
@@ -77,6 +98,11 @@ tasks:
     cmds:
       - prettier --write internal/cmd/stats/index.html internal/cmd/stats/index.css internal/cmd/stats/index.js
 
+  modernize:
+    desc: Run modernize
+    cmds:
+      - go run golang.org/x/tools/go/analysis/passes/modernize/cmd/modernize@latest -fix -test ./...
+
   dev:
     desc: Run with profiling enabled
     env:
@@ -91,6 +117,9 @@ tasks:
     cmds:
       - task: fetch-tags
       - go install {{.LDFLAGS}} -v .
+    sources:
+      - ./**/*.go
+      - go.mod
 
   profile:cpu:
     desc: 10s CPU profile

go.mod 🔗

@@ -4,9 +4,9 @@ go 1.25.5
 
 require (
 	charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66
-	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
-	charm.land/catwalk v0.16.1
-	charm.land/fantasy v0.7.0
+	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0
+	charm.land/catwalk v0.17.1
+	charm.land/fantasy v0.7.2
 	charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
 	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
 	charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
@@ -22,21 +22,20 @@ require (
 	github.com/charlievieth/fastwalk v1.0.14
 	github.com/charmbracelet/colorprofile v0.4.1
 	github.com/charmbracelet/fang v0.4.4
-	github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
-	github.com/charmbracelet/x/ansi v0.11.4
+	github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
+	github.com/charmbracelet/x/ansi v0.11.6
 	github.com/charmbracelet/x/editor v0.2.0
 	github.com/charmbracelet/x/etag v0.2.0
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f
 	github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
 	github.com/charmbracelet/x/exp/ordered v0.1.0
-	github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
+	github.com/charmbracelet/x/exp/slice v0.0.0-20260209194814-eeb2896ac759
 	github.com/charmbracelet/x/exp/strings v0.1.0
-	github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
+	github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c
 	github.com/charmbracelet/x/term v0.2.2
-	github.com/clipperhouse/displaywidth v0.9.0
-	github.com/clipperhouse/uax29/v2 v2.5.0
+	github.com/clipperhouse/displaywidth v0.10.0
+	github.com/clipperhouse/uax29/v2 v2.6.0
 	github.com/denisbrodbeck/machineid v1.0.1
-	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/disintegration/imaging v1.6.2
 	github.com/dustin/go-humanize v1.0.1
 	github.com/google/uuid v1.6.0
@@ -46,25 +45,22 @@ require (
 	github.com/lucasb-eyer/go-colorful v1.3.0
 	github.com/mattn/go-isatty v0.0.20
 	github.com/modelcontextprotocol/go-sdk v1.2.0
-	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.30.5
-	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 	github.com/nxadm/tail v1.4.11
 	github.com/openai/openai-go/v2 v2.7.1
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
-	github.com/posthog/posthog-go v1.9.1
+	github.com/posthog/posthog-go v1.10.0
 	github.com/pressly/goose/v3 v3.26.0
 	github.com/rivo/uniseg v0.4.7
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 	github.com/sahilm/fuzzy v0.1.1
+	github.com/sourcegraph/jsonrpc2 v0.2.1
 	github.com/spf13/cobra v1.10.2
-	github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
-	github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/gjson v1.18.0
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.1.0
-	golang.org/x/mod v0.32.0
+	go.uber.org/goleak v1.3.0
 	golang.org/x/net v0.49.0
 	golang.org/x/sync v0.19.0
 	golang.org/x/text v0.33.0
@@ -99,7 +95,6 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
 	github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
 	github.com/aws/smithy-go v1.24.0 // indirect
-	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
@@ -110,7 +105,6 @@ require (
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/clipperhouse/stringish v0.1.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -134,7 +128,7 @@ require (
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/kaptinlin/go-i18n v0.2.3 // indirect
 	github.com/kaptinlin/jsonpointer v0.4.9 // indirect
-	github.com/kaptinlin/jsonschema v0.6.9 // indirect
+	github.com/kaptinlin/jsonschema v0.6.10 // indirect
 	github.com/kaptinlin/messageformat-go v0.4.9 // indirect
 	github.com/klauspost/compress v1.18.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
@@ -156,7 +150,6 @@ require (
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect
-	github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/tetratelabs/wazero v1.11.0 // indirect
 	github.com/tidwall/match v1.1.1 // indirect
@@ -179,12 +172,13 @@ require (
 	golang.org/x/crypto v0.47.0 // indirect
 	golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
 	golang.org/x/image v0.34.0 // indirect
-	golang.org/x/oauth2 v0.34.0 // indirect
+	golang.org/x/mod v0.32.0 // indirect
+	golang.org/x/oauth2 v0.35.0 // indirect
 	golang.org/x/sys v0.40.0 // indirect
 	golang.org/x/term v0.39.0 // indirect
 	golang.org/x/time v0.14.0 // indirect
 	google.golang.org/api v0.239.0 // indirect
-	google.golang.org/genai v1.44.0 // indirect
+	google.golang.org/genai v1.45.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
 	google.golang.org/grpc v1.76.0 // indirect
 	google.golang.org/protobuf v1.36.10 // indirect

go.sum 🔗

@@ -1,11 +1,11 @@
 charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv9xq6ZS+x0mtacfxpxjIK1KUIeTqBOs=
 charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
-charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
-charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
-charm.land/catwalk v0.16.1 h1:4Z4uCxqdAaVHeSX5dDDOkOg8sm7krFqJSaNBMZhE7Ao=
-charm.land/catwalk v0.16.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64=
-charm.land/fantasy v0.7.0 h1:qsSKJF07B+mimpPaC61Zyu3N+A9l2Lbs6T3txlP5In8=
-charm.land/fantasy v0.7.0/go.mod h1:zv8Utaob4b9rSPp2ruH515rx7oN+l66gv6RshvwHnww=
+charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0 h1:HAbpM9TPjZM18D677ww3VnkKXdd2hyMQtHUsVV0HcPQ=
+charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
+charm.land/catwalk v0.17.1 h1:UsHvBi3S7CxONiIZTWKTXM+H9qla8I0fCb/SVru33ms=
+charm.land/catwalk v0.17.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64=
+charm.land/fantasy v0.7.2 h1:OUBgbs7hllZE7rpJP9SzdsGE/hMCm+mr11iEIqU02hE=
+charm.land/fantasy v0.7.2/go.mod h1:vH6F5eYqaxgNEvDQdXRsOsfvoRyT3f/uJngPNJmcDmw=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c=
@@ -82,8 +82,6 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
 github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
 github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM=
 github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -104,10 +102,10 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco
 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
 github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
 github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
-github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 h1:j3PW2hypGoPKBy3ooKzW0TFxaxhyHK3NbkLLn4KeRFc=
-github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560/go.mod h1:VWATWLRwYP06VYCEur7FsNR2B1xAo7Y+xl1PTbd1ePc=
-github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
-github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
 github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk=
 github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
 github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04=
@@ -118,26 +116,26 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g
 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
 github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
 github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
-github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/JS+qnRcO/++xjYEDtW7x+P5E4+4cBiOHTt2Xfk=
-github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
+github.com/charmbracelet/x/exp/slice v0.0.0-20260209194814-eeb2896ac759 h1:96wFGlst+IDv3dIf5q29nw470wJYB3YAgemiciLZcG0=
+github.com/charmbracelet/x/exp/slice v0.0.0-20260209194814-eeb2896ac759/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
 github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
 github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
 github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
-github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8=
-github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c h1:6E+Y7WQ6Rnw+FmeXoRBtyCBkPcXS0hSMuws6QBr+nyQ=
+github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
-github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
+github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
+github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
 github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
 github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
-github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
+github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
 github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
 github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -148,10 +146,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
 github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
-github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
-github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
-github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
-github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
 github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
@@ -230,8 +224,8 @@ github.com/kaptinlin/go-i18n v0.2.3 h1:jyN/YOXXLcnGRBLdU+a8+6782B97fWE5aQqAHtvvk
 github.com/kaptinlin/go-i18n v0.2.3/go.mod h1:O+Ax4HkMO0Jt4OaP4E4WCx0PAADeWkwk8Jgt9bjAU1w=
 github.com/kaptinlin/jsonpointer v0.4.9 h1:o//bYf4PCvnMJIIX8bIg77KB6DO3wBPAabRyPRKh680=
 github.com/kaptinlin/jsonpointer v0.4.9/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
-github.com/kaptinlin/jsonschema v0.6.9 h1:N6bwMCadb0fA9CYINqQbtPhacIIjXmAjuYnJaWeI1bg=
-github.com/kaptinlin/jsonschema v0.6.9/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8=
+github.com/kaptinlin/jsonschema v0.6.10 h1:CYded7nrwVu7pU1GaIjtd9dSzgqZjh7+LTKFaWqS08I=
+github.com/kaptinlin/jsonschema v0.6.10/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8=
 github.com/kaptinlin/messageformat-go v0.4.9 h1:FR5j5n4aL4nG0afKn9vvANrKxLu7HjmbhJnw5ogIwAQ=
 github.com/kaptinlin/messageformat-go v0.4.9/go.mod h1:qZzrGrlvWDz2KyyvN3dOWcK9PVSRV1BnfnNU+zB/RWc=
 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -275,16 +269,12 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
 github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
 github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
-github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
-github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
 github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
 github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
 github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
 github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
 github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
@@ -300,8 +290,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.9.1 h1:9bkcRnYSvcgMxL2s9QlCnd1DVnm2qWXxWu5o0HSF0xM=
-github.com/posthog/posthog-go v1.9.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY=
+github.com/posthog/posthog-go v1.10.0 h1:wfoy7Jfb4LigCoHYyMZoiJmmEoCLOkSaYfDxM/NtCqY=
+github.com/posthog/posthog-go v1.10.0/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY=
 github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
 github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -329,10 +319,6 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
-github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
-github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
-github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -387,6 +373,8 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh
 go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
 go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -426,8 +414,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
 golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
-golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
-golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -494,8 +482,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
 gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
 google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
 google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
-google.golang.org/genai v1.44.0 h1:+nn8oXANzrpHsWxGfZz2IySq0cFPiepqFvgMFofK8vw=
-google.golang.org/genai v1.44.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genai v1.45.0 h1:s80ZpS42XW0zu/ogiOtenCio17nJ7reEFJjoCftukpA=
+google.golang.org/genai v1.45.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=

internal/agent/agentic_fetch_tool.go 🔗

@@ -169,7 +169,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				tools.NewGlobTool(tmpDir),
 				tools.NewGrepTool(tmpDir),
 				tools.NewSourcegraphTool(client),
-				tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
+				tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
 			}
 
 			agent := NewSessionAgent(SessionAgentOptions{

internal/agent/common_test.go 🔗

@@ -204,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 	allTools := []fantasy.AgentTool{
 		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
 		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
-		tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
-		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
+		tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
+		tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
 		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewGlobTool(env.workingDir),
 		tools.NewGrepTool(env.workingDir),
 		tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
 		tools.NewSourcegraphTool(r.GetDefaultClient()),
-		tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
-		tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
+		tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
+		tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
 	}
 
 	return testSessionAgent(env, large, small, systemPrompt, allTools...), nil

internal/agent/coordinator.go 🔗

@@ -18,7 +18,6 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/prompt"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/log"
@@ -63,7 +62,7 @@ type coordinator struct {
 	permissions permission.Service
 	history     history.Service
 	filetracker filetracker.Service
-	lspClients  *csync.Map[string, *lsp.Client]
+	lspManager  *lsp.Manager
 
 	currentAgent SessionAgent
 	agents       map[string]SessionAgent
@@ -79,7 +78,7 @@ func NewCoordinator(
 	permissions permission.Service,
 	history history.Service,
 	filetracker filetracker.Service,
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 ) (Coordinator, error) {
 	c := &coordinator{
 		cfg:         cfg,
@@ -88,7 +87,7 @@ func NewCoordinator(
 		permissions: permissions,
 		history:     history,
 		filetracker: filetracker,
-		lspClients:  lspClients,
+		lspManager:  lspManager,
 		agents:      make(map[string]SessionAgent),
 	}
 
@@ -386,20 +385,29 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		tools.NewJobOutputTool(),
 		tools.NewJobKillTool(),
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
-		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
-		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
 		tools.NewGlobTool(c.cfg.WorkingDir()),
 		tools.NewGrepTool(c.cfg.WorkingDir()),
 		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
 		tools.NewSourcegraphTool(nil),
 		tools.NewTodosTool(c.sessions),
-		tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
-		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
+		tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 	)
 
-	if c.lspClients.Len() > 0 {
-		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
+	// Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
+	if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
+		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
+	}
+
+	if len(c.cfg.MCP) > 0 {
+		allTools = append(
+			allTools,
+			tools.NewListMCPResourcesTool(c.cfg, c.permissions),
+			tools.NewReadMCPResourceTool(c.cfg, c.permissions),
+		)
 	}
 
 	var filteredTools []fantasy.AgentTool
@@ -409,7 +417,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		}
 	}
 
-	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
+	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
 		if agent.AllowedMCP == nil {
 			// No MCP restrictions
 			filteredTools = append(filteredTools, tool)

internal/agent/hyper/provider.go 🔗

@@ -27,7 +27,7 @@ import (
 	"github.com/charmbracelet/crush/internal/event"
 )
 
-//go:generate wget -O provider.json https://console.charm.land/api/v1/provider
+//go:generate wget -O provider.json https://hyper.charm.land/api/v1/provider
 
 //go:embed provider.json
 var embedded []byte
@@ -61,8 +61,7 @@ const (
 	// Name is the default name of this meta provider.
 	Name = "hyper"
 	// defaultBaseURL is the default proxy URL.
-	// TODO: change this to production URL when ready.
-	defaultBaseURL = "https://console.charm.land"
+	defaultBaseURL = "https://hyper.charm.land"
 )
 
 // BaseURL returns the base URL, which is either $HYPER_URL or the default.
@@ -253,10 +252,16 @@ func (m *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.
 				continue
 			}
 		}
-		if err := scanner.Err(); err != nil &&
-			!errors.Is(err, context.Canceled) &&
-			!errors.Is(err, context.DeadlineExceeded) {
-			yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
+		if err := scanner.Err(); err != nil {
+			if sawFinish && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
+				// If we already saw an explicit finish event, treat cancellation as a no-op.
+			} else {
+				_ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
+				return
+			}
+		}
+		if err := ctx.Err(); err != nil && !sawFinish {
+			_ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
 			return
 		}
 		// flush any pending data

internal/agent/tools/diagnostics.go 🔗

@@ -10,7 +10,6 @@ import (
 	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
@@ -24,25 +23,36 @@ const DiagnosticsToolName = "lsp_diagnostics"
 //go:embed diagnostics.md
 var diagnosticsDescription []byte
 
-func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+func NewDiagnosticsTool(lspManager *lsp.Manager) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		DiagnosticsToolName,
 		string(diagnosticsDescription),
 		func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
-			if lspClients.Len() == 0 {
+			if lspManager.Clients().Len() == 0 {
 				return fantasy.NewTextErrorResponse("no LSP clients available"), nil
 			}
-			notifyLSPs(ctx, lspClients, params.FilePath)
-			output := getDiagnostics(params.FilePath, lspClients)
+			notifyLSPs(ctx, lspManager, params.FilePath)
+			output := getDiagnostics(params.FilePath, lspManager)
 			return fantasy.NewTextResponse(output), nil
 		})
 }
 
-func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) {
+func notifyLSPs(
+	ctx context.Context,
+	manager *lsp.Manager,
+	filepath string,
+) {
 	if filepath == "" {
 		return
 	}
-	for client := range lsps.Seq() {
+
+	if manager == nil {
+		return
+	}
+
+	manager.Start(ctx, filepath)
+
+	for client := range manager.Clients().Seq() {
 		if !client.HandlesFile(filepath) {
 			continue
 		}
@@ -52,11 +62,15 @@ func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filep
 	}
 }
 
-func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) string {
-	fileDiagnostics := []string{}
-	projectDiagnostics := []string{}
+func getDiagnostics(filePath string, manager *lsp.Manager) string {
+	if manager == nil {
+		return ""
+	}
+
+	var fileDiagnostics []string
+	var projectDiagnostics []string
 
-	for lspName, client := range lsps.Seq2() {
+	for lspName, client := range manager.Clients().Seq2() {
 		for location, diags := range client.GetDiagnostics() {
 			path, err := location.Path()
 			if err != nil {
@@ -149,7 +163,7 @@ func formatDiagnostic(pth string, diagnostic protocol.Diagnostic, source string)
 
 	tagsInfo := ""
 	if len(diagnostic.Tags) > 0 {
-		tags := []string{}
+		var tags []string
 		for _, tag := range diagnostic.Tags {
 			switch tag {
 			case protocol.Unnecessary:

internal/agent/tools/edit.go 🔗

@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/filetracker"
@@ -61,7 +60,7 @@ type editContext struct {
 }
 
 func NewEditTool(
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 	permissions permission.Service,
 	files history.Service,
 	filetracker filetracker.Service,
@@ -99,10 +98,10 @@ func NewEditTool(
 				return response, nil
 			}
 
-			notifyLSPs(ctx, lspClients, params.FilePath)
+			notifyLSPs(ctx, lspManager, params.FilePath)
 
 			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
-			text += getDiagnostics(params.FilePath, lspClients)
+			text += getDiagnostics(params.FilePath, lspManager)
 			response.Content = text
 			return response, nil
 		})

internal/agent/tools/grep.go 🔗

@@ -15,53 +15,41 @@ import (
 	"regexp"
 	"sort"
 	"strings"
-	"sync"
 	"time"
 
 	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/fsext"
 )
 
 // regexCache provides thread-safe caching of compiled regex patterns
 type regexCache struct {
-	cache map[string]*regexp.Regexp
-	mu    sync.RWMutex
+	*csync.Map[string, *regexp.Regexp]
 }
 
 // newRegexCache creates a new regex cache
 func newRegexCache() *regexCache {
 	return &regexCache{
-		cache: make(map[string]*regexp.Regexp),
+		Map: csync.NewMap[string, *regexp.Regexp](),
 	}
 }
 
 // get retrieves a compiled regex from cache or compiles and caches it
 func (rc *regexCache) get(pattern string) (*regexp.Regexp, error) {
-	// Try to get from cache first (read lock)
-	rc.mu.RLock()
-	if regex, exists := rc.cache[pattern]; exists {
-		rc.mu.RUnlock()
-		return regex, nil
-	}
-	rc.mu.RUnlock()
-
-	// Compile the regex (write lock)
-	rc.mu.Lock()
-	defer rc.mu.Unlock()
-
-	// Double-check in case another goroutine compiled it while we waited
-	if regex, exists := rc.cache[pattern]; exists {
-		return regex, nil
-	}
-
-	// Compile and cache the regex
-	regex, err := regexp.Compile(pattern)
-	if err != nil {
-		return nil, err
-	}
+	var rerr error
+	return rc.GetOrSet(pattern, func() *regexp.Regexp {
+		regex, err := regexp.Compile(pattern)
+		if err != nil {
+			rerr = err
+		}
+		return regex
+	}), rerr
+}
 
-	rc.cache[pattern] = regex
-	return regex, nil
+// ResetCache clears compiled regex caches to prevent unbounded growth across sessions.
+func ResetCache() {
+	searchRegexCache.Reset(map[string]*regexp.Regexp{})
+	globRegexCache.Reset(map[string]*regexp.Regexp{})
 }
 
 // Global regex cache instances

internal/agent/tools/list_mcp_resources.go 🔗

@@ -0,0 +1,104 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"sort"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type ListMCPResourcesParams struct {
+	MCPName string `json:"mcp_name" description:"The MCP server name"`
+}
+
+type ListMCPResourcesPermissionsParams struct {
+	MCPName string `json:"mcp_name"`
+}
+
+const ListMCPResourcesToolName = "list_mcp_resources"
+
+//go:embed list_mcp_resources.md
+var listMCPResourcesDescription []byte
+
+func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
+	return fantasy.NewParallelAgentTool(
+		ListMCPResourcesToolName,
+		string(listMCPResourcesDescription),
+		func(ctx context.Context, params ListMCPResourcesParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			params.MCPName = strings.TrimSpace(params.MCPName)
+			if params.MCPName == "" {
+				return fantasy.NewTextErrorResponse("mcp_name parameter is required"), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for listing MCP resources")
+			}
+
+			relPath := filepathext.SmartJoin(cfg.WorkingDir(), params.MCPName)
+			p, err := permissions.Request(ctx,
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        relPath,
+					ToolCallID:  call.ID,
+					ToolName:    ListMCPResourcesToolName,
+					Action:      "list",
+					Description: fmt.Sprintf("List MCP resources from %s", params.MCPName),
+					Params:      ListMCPResourcesPermissionsParams(params),
+				},
+			)
+			if err != nil {
+				return fantasy.ToolResponse{}, err
+			}
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			resources, err := mcp.ListResources(ctx, cfg, params.MCPName)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+			if len(resources) == 0 {
+				return fantasy.NewTextResponse("No resources found"), nil
+			}
+
+			lines := make([]string, 0, len(resources))
+			for _, resource := range resources {
+				if resource == nil {
+					continue
+				}
+				title := resource.Title
+				if title == "" {
+					title = resource.Name
+				}
+				if title == "" {
+					title = resource.URI
+				}
+				line := fmt.Sprintf("- %s", title)
+				if resource.URI != "" {
+					line = fmt.Sprintf("%s (%s)", line, resource.URI)
+				}
+				if resource.Description != "" {
+					line = fmt.Sprintf("%s: %s", line, resource.Description)
+				}
+				if resource.MIMEType != "" {
+					line = fmt.Sprintf("%s [mime: %s]", line, resource.MIMEType)
+				}
+				if resource.Size > 0 {
+					line = fmt.Sprintf("%s [size: %d]", line, resource.Size)
+				}
+				lines = append(lines, line)
+			}
+
+			sort.Strings(lines)
+			return fantasy.NewTextResponse(strings.Join(lines, "\n")), nil
+		},
+	)
+}

internal/agent/tools/list_mcp_resources.md 🔗

@@ -0,0 +1,18 @@
+Lists available resources from an MCP server.
+
+<when_to_use>
+Use this tool to discover which resources are available before reading them.
+</when_to_use>
+
+<usage>
+- Provide MCP server name
+- Returns resource titles and URIs
+</usage>
+
+<parameters>
+- mcp_name: The MCP server name
+</parameters>
+
+<notes>
+- Results include resource titles, URIs, and metadata when available
+</notes>

internal/agent/tools/lsp_restart.go 🔗

@@ -10,7 +10,6 @@ import (
 	"sync"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 )
 
@@ -25,20 +24,20 @@ type LSPRestartParams struct {
 	Name string `json:"name,omitempty"`
 }
 
-func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+func NewLSPRestartTool(lspManager *lsp.Manager) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		LSPRestartToolName,
 		string(lspRestartDescription),
 		func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
-			if lspClients.Len() == 0 {
+			if lspManager.Clients().Len() == 0 {
 				return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil
 			}
 
 			clientsToRestart := make(map[string]*lsp.Client)
 			if params.Name == "" {
-				maps.Insert(clientsToRestart, lspClients.Seq2())
+				maps.Insert(clientsToRestart, lspManager.Clients().Seq2())
 			} else {
-				client, exists := lspClients.Get(params.Name)
+				client, exists := lspManager.Clients().Get(params.Name)
 				if !exists {
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil
 				}

internal/agent/tools/mcp-tools.go 🔗

@@ -6,11 +6,12 @@ import (
 
 	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/permission"
 )
 
 // GetMCPTools gets all the currently available MCP tools.
-func GetMCPTools(permissions permission.Service, wd string) []*Tool {
+func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) []*Tool {
 	var result []*Tool
 	for mcpName, tools := range mcp.Tools() {
 		for _, tool := range tools {
@@ -19,6 +20,7 @@ func GetMCPTools(permissions permission.Service, wd string) []*Tool {
 				tool:        tool,
 				permissions: permissions,
 				workingDir:  wd,
+				cfg:         cfg,
 			})
 		}
 	}
@@ -29,6 +31,7 @@ func GetMCPTools(permissions permission.Service, wd string) []*Tool {
 type Tool struct {
 	mcpName         string
 	tool            *mcp.Tool
+	cfg             *config.Config
 	permissions     permission.Service
 	workingDir      string
 	providerOptions fantasy.ProviderOptions
@@ -107,7 +110,7 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
 		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
-	result, err := mcp.RunTool(ctx, m.mcpName, m.tool.Name, params.Input)
+	result, err := mcp.RunTool(ctx, m.cfg, m.mcpName, m.tool.Name, params.Input)
 	if err != nil {
 		return fantasy.NewTextErrorResponse(err.Error()), nil
 	}

internal/agent/tools/mcp/init.go 🔗

@@ -25,8 +25,35 @@ import (
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
+func parseLevel(level mcp.LoggingLevel) slog.Level {
+	switch level {
+	case "info":
+		return slog.LevelInfo
+	case "notice":
+		return slog.LevelInfo
+	case "warning":
+		return slog.LevelWarn
+	default:
+		return slog.LevelDebug
+	}
+}
+
+// ClientSession wraps an mcp.ClientSession with a context cancel function so
+// that the context created during session establishment is properly cleaned up
+// on close.
+type ClientSession struct {
+	*mcp.ClientSession
+	cancel context.CancelFunc
+}
+
+// Close cancels the session context and then closes the underlying session.
+func (s *ClientSession) Close() error {
+	s.cancel()
+	return s.ClientSession.Close()
+}
+
 var (
-	sessions = csync.NewMap[string, *mcp.ClientSession]()
+	sessions = csync.NewMap[string, *ClientSession]()
 	states   = csync.NewMap[string, ClientInfo]()
 	broker   = pubsub.NewBroker[Event]()
 	initOnce sync.Once
@@ -65,6 +92,7 @@ const (
 	EventStateChanged EventType = iota
 	EventToolsListChanged
 	EventPromptsListChanged
+	EventResourcesListChanged
 )
 
 // Event represents an event in the MCP system
@@ -78,8 +106,9 @@ type Event struct {
 
 // Counts number of available tools, prompts, etc.
 type Counts struct {
-	Tools   int
-	Prompts int
+	Tools     int
+	Prompts   int
+	Resources int
 }
 
 // ClientInfo holds information about an MCP client's state
@@ -87,7 +116,7 @@ type ClientInfo struct {
 	Name        string
 	State       State
 	Error       error
-	Client      *mcp.ClientSession
+	Client      *ClientSession
 	Counts      Counts
 	ConnectedAt time.Time
 }
@@ -108,27 +137,27 @@ func GetState(name string) (ClientInfo, bool) {
 }
 
 // Close closes all MCP clients. This should be called during application shutdown.
-func Close() error {
+func Close(ctx context.Context) error {
 	var wg sync.WaitGroup
-	done := make(chan struct{}, 1)
-	go func() {
-		for name, session := range sessions.Seq2() {
-			wg.Go(func() {
-				if err := session.Close(); err != nil &&
+	for name, session := range sessions.Seq2() {
+		wg.Go(func() {
+			done := make(chan error, 1)
+			go func() {
+				done <- session.Close()
+			}()
+			select {
+			case err := <-done:
+				if err != nil &&
 					!errors.Is(err, io.EOF) &&
 					!errors.Is(err, context.Canceled) &&
 					err.Error() != "signal: killed" {
 					slog.Warn("Failed to shutdown MCP client", "name", name, "error", err)
 				}
-			})
-		}
-		wg.Wait()
-		done <- struct{}{}
-	}()
-	select {
-	case <-done:
-	case <-time.After(5 * time.Second):
+			case <-ctx.Done():
+			}
+		})
 	}
+	wg.Wait()
 	broker.Shutdown()
 	return nil
 }
@@ -189,13 +218,23 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 				return
 			}
 
-			toolCount := updateTools(name, tools)
+			resources, err := getResources(ctx, session)
+			if err != nil {
+				slog.Error("Error listing resources", "error", err)
+				updateState(name, StateError, err, nil, Counts{})
+				session.Close()
+				return
+			}
+
+			toolCount := updateTools(cfg, name, tools)
 			updatePrompts(name, prompts)
+			resourceCount := updateResources(name, resources)
 			sessions.Set(name, session)
 
 			updateState(name, StateConnected, nil, session, Counts{
-				Tools:   toolCount,
-				Prompts: len(prompts),
+				Tools:     toolCount,
+				Prompts:   len(prompts),
+				Resources: resourceCount,
 			})
 		}(name, m)
 	}
@@ -214,13 +253,12 @@ func WaitForInit(ctx context.Context) error {
 	}
 }
 
-func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
+func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*ClientSession, error) {
 	sess, ok := sessions.Get(name)
 	if !ok {
 		return nil, fmt.Errorf("mcp '%s' not available", name)
 	}
 
-	cfg := config.Get()
 	m := cfg.MCP[name]
 	state, _ := states.Get(name)
 
@@ -244,7 +282,7 @@ func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, err
 }
 
 // updateState updates the state of an MCP client and publishes an event
-func updateState(name string, state State, err error, client *mcp.ClientSession, counts Counts) {
+func updateState(name string, state State, err error, client *ClientSession, counts Counts) {
 	info := ClientInfo{
 		Name:   name,
 		State:  state,
@@ -270,7 +308,7 @@ func updateState(name string, state State, err error, client *mcp.ClientSession,
 	})
 }
 
-func createSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
+func createSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*ClientSession, error) {
 	timeout := mcpTimeout(m)
 	mcpCtx, cancel := context.WithCancel(ctx)
 	cancelTimer := time.AfterFunc(timeout, cancel)
@@ -303,8 +341,15 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 					Name: name,
 				})
 			},
-			LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) {
-				slog.Info("MCP log", "name", name, "data", req.Params.Data)
+			ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) {
+				broker.Publish(pubsub.UpdatedEvent, Event{
+					Type: EventResourcesListChanged,
+					Name: name,
+				})
+			},
+			LoggingMessageHandler: func(ctx context.Context, req *mcp.LoggingMessageRequest) {
+				level := parseLevel(req.Params.Level)
+				slog.Log(ctx, level, "MCP log", "name", name, "logger", req.Params.Logger, "data", req.Params.Data)
 			},
 		},
 	)
@@ -321,7 +366,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 
 	cancelTimer.Stop()
 	slog.Debug("MCP client initialized", "name", name)
-	return session, nil
+	return &ClientSession{session, cancel}, nil
 }
 
 // maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail

internal/agent/tools/mcp/init_test.go 🔗

@@ -0,0 +1,38 @@
+package mcp
+
+import (
+	"context"
+	"testing"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+)
+
+func TestMCPSession_CancelOnClose(t *testing.T) {
+	defer goleak.VerifyNone(t)
+
+	serverTransport, clientTransport := mcp.NewInMemoryTransports()
+
+	server := mcp.NewServer(&mcp.Implementation{Name: "test-server"}, nil)
+	serverSession, err := server.Connect(context.Background(), serverTransport, nil)
+	require.NoError(t, err)
+	defer serverSession.Close()
+
+	ctx, cancel := context.WithCancel(context.Background())
+
+	client := mcp.NewClient(&mcp.Implementation{Name: "crush-test"}, nil)
+	clientSession, err := client.Connect(ctx, clientTransport, nil)
+	require.NoError(t, err)
+
+	sess := &ClientSession{clientSession, cancel}
+
+	// Verify the context is not cancelled before close.
+	require.NoError(t, ctx.Err())
+
+	err = sess.Close()
+	require.NoError(t, err)
+
+	// After Close, the context must be cancelled.
+	require.ErrorIs(t, ctx.Err(), context.Canceled)
+}

internal/agent/tools/mcp/prompts.go 🔗

@@ -5,6 +5,7 @@ import (
 	"iter"
 	"log/slog"
 
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
@@ -19,8 +20,8 @@ func Prompts() iter.Seq2[string, []*Prompt] {
 }
 
 // GetPromptMessages retrieves the content of an MCP prompt with the given arguments.
-func GetPromptMessages(ctx context.Context, clientName, promptName string, args map[string]string) ([]string, error) {
-	c, err := getOrRenewClient(ctx, clientName)
+func GetPromptMessages(ctx context.Context, cfg *config.Config, clientName, promptName string, args map[string]string) ([]string, error) {
+	c, err := getOrRenewClient(ctx, cfg, clientName)
 	if err != nil {
 		return nil, err
 	}
@@ -66,7 +67,7 @@ func RefreshPrompts(ctx context.Context, name string) {
 	updateState(name, StateConnected, nil, session, prev.Counts)
 }
 
-func getPrompts(ctx context.Context, c *mcp.ClientSession) ([]*Prompt, error) {
+func getPrompts(ctx context.Context, c *ClientSession) ([]*Prompt, error) {
 	if c.InitializeResult().Capabilities.Prompts == nil {
 		return nil, nil
 	}

internal/agent/tools/mcp/resources.go 🔗

@@ -0,0 +1,96 @@
+package mcp
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type Resource = mcp.Resource
+
+type ResourceContents = mcp.ResourceContents
+
+var allResources = csync.NewMap[string, []*Resource]()
+
+// Resources returns all available MCP resources.
+func Resources() iter.Seq2[string, []*Resource] {
+	return allResources.Seq2()
+}
+
+// ListResources returns the current resources for an MCP server.
+func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Resource, error) {
+	session, err := getOrRenewClient(ctx, cfg, name)
+	if err != nil {
+		return nil, err
+	}
+
+	resources, err := getResources(ctx, session)
+	if err != nil {
+		return nil, err
+	}
+
+	resourceCount := updateResources(name, resources)
+	prev, _ := states.Get(name)
+	prev.Counts.Resources = resourceCount
+	updateState(name, StateConnected, nil, session, prev.Counts)
+	return resources, nil
+}
+
+// ReadResource reads the contents of a resource from an MCP server.
+func ReadResource(ctx context.Context, cfg *config.Config, name, uri string) ([]*ResourceContents, error) {
+	session, err := getOrRenewClient(ctx, cfg, name)
+	if err != nil {
+		return nil, err
+	}
+	result, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri})
+	if err != nil {
+		return nil, err
+	}
+	return result.Contents, nil
+}
+
+// RefreshResources gets the updated list of resources from the MCP and updates the
+// global state.
+func RefreshResources(ctx context.Context, name string) {
+	session, ok := sessions.Get(name)
+	if !ok {
+		slog.Warn("Refresh resources: no session", "name", name)
+		return
+	}
+
+	resources, err := getResources(ctx, session)
+	if err != nil {
+		updateState(name, StateError, err, nil, Counts{})
+		return
+	}
+
+	resourceCount := updateResources(name, resources)
+
+	prev, _ := states.Get(name)
+	prev.Counts.Resources = resourceCount
+	updateState(name, StateConnected, nil, session, prev.Counts)
+}
+
+func getResources(ctx context.Context, c *ClientSession) ([]*Resource, error) {
+	if c.InitializeResult().Capabilities.Resources == nil {
+		return nil, nil
+	}
+	result, err := c.ListResources(ctx, &mcp.ListResourcesParams{})
+	if err != nil {
+		return nil, err
+	}
+	return result.Resources, nil
+}
+
+func updateResources(name string, resources []*Resource) int {
+	if len(resources) == 0 {
+		allResources.Del(name)
+		return 0
+	}
+	allResources.Set(name, resources)
+	return len(resources)
+}

internal/agent/tools/mcp/tools.go 🔗

@@ -32,13 +32,13 @@ func Tools() iter.Seq2[string, []*Tool] {
 }
 
 // RunTool runs an MCP tool with the given input parameters.
-func RunTool(ctx context.Context, name, toolName string, input string) (ToolResult, error) {
+func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, input string) (ToolResult, error) {
 	var args map[string]any
 	if err := json.Unmarshal([]byte(input), &args); err != nil {
 		return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err)
 	}
 
-	c, err := getOrRenewClient(ctx, name)
+	c, err := getOrRenewClient(ctx, cfg, name)
 	if err != nil {
 		return ToolResult{}, err
 	}
@@ -108,7 +108,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu
 
 // RefreshTools gets the updated list of tools from the MCP and updates the
 // global state.
-func RefreshTools(ctx context.Context, name string) {
+func RefreshTools(ctx context.Context, cfg *config.Config, name string) {
 	session, ok := sessions.Get(name)
 	if !ok {
 		slog.Warn("Refresh tools: no session", "name", name)
@@ -121,14 +121,14 @@ func RefreshTools(ctx context.Context, name string) {
 		return
 	}
 
-	toolCount := updateTools(name, tools)
+	toolCount := updateTools(cfg, name, tools)
 
 	prev, _ := states.Get(name)
 	prev.Counts.Tools = toolCount
 	updateState(name, StateConnected, nil, session, prev.Counts)
 }
 
-func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error) {
+func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) {
 	// Always call ListTools to get the actual available tools.
 	// The InitializeResult Capabilities.Tools field may be an empty object {},
 	// which is valid per MCP spec, but we still need to call ListTools to discover tools.
@@ -139,8 +139,8 @@ func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error)
 	return result.Tools, nil
 }
 
-func updateTools(name string, tools []*Tool) int {
-	tools = filterDisabledTools(name, tools)
+func updateTools(cfg *config.Config, name string, tools []*Tool) int {
+	tools = filterDisabledTools(cfg, name, tools)
 	if len(tools) == 0 {
 		allTools.Del(name)
 		return 0
@@ -150,8 +150,7 @@ func updateTools(name string, tools []*Tool) int {
 }
 
 // filterDisabledTools removes tools that are disabled via config.
-func filterDisabledTools(mcpName string, tools []*Tool) []*Tool {
-	cfg := config.Get()
+func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool {
 	mcpCfg, ok := cfg.MCP[mcpName]
 	if !ok || len(mcpCfg.DisabledTools) == 0 {
 		return tools

internal/agent/tools/multiedit.go 🔗

@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/filetracker"
@@ -59,7 +58,7 @@ const MultiEditToolName = "multiedit"
 var multieditDescription []byte
 
 func NewMultiEditTool(
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 	permissions permission.Service,
 	files history.Service,
 	filetracker filetracker.Service,
@@ -104,11 +103,11 @@ func NewMultiEditTool(
 			}
 
 			// Notify LSP clients about the change
-			notifyLSPs(ctx, lspClients, params.FilePath)
+			notifyLSPs(ctx, lspManager, params.FilePath)
 
 			// Wait for LSP diagnostics and add them to the response
 			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
-			text += getDiagnostics(params.FilePath, lspClients)
+			text += getDiagnostics(params.FilePath, lspManager)
 			response.Content = text
 			return response, nil
 		})

internal/agent/tools/read_mcp_resource.go 🔗

@@ -0,0 +1,102 @@
+package tools
+
+import (
+	"cmp"
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type ReadMCPResourceParams struct {
+	MCPName string `json:"mcp_name" description:"The MCP server name"`
+	URI     string `json:"uri" description:"The resource URI to read"`
+}
+
+type ReadMCPResourcePermissionsParams struct {
+	MCPName string `json:"mcp_name"`
+	URI     string `json:"uri"`
+}
+
+const ReadMCPResourceToolName = "read_mcp_resource"
+
+//go:embed read_mcp_resource.md
+var readMCPResourceDescription []byte
+
+func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
+	return fantasy.NewParallelAgentTool(
+		ReadMCPResourceToolName,
+		string(readMCPResourceDescription),
+		func(ctx context.Context, params ReadMCPResourceParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			params.MCPName = strings.TrimSpace(params.MCPName)
+			params.URI = strings.TrimSpace(params.URI)
+			if params.MCPName == "" {
+				return fantasy.NewTextErrorResponse("mcp_name parameter is required"), nil
+			}
+			if params.URI == "" {
+				return fantasy.NewTextErrorResponse("uri parameter is required"), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for reading MCP resources")
+			}
+
+			relPath := filepathext.SmartJoin(cfg.WorkingDir(), cmp.Or(params.URI, "mcp-resource"))
+			p, err := permissions.Request(ctx,
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        relPath,
+					ToolCallID:  call.ID,
+					ToolName:    ReadMCPResourceToolName,
+					Action:      "read",
+					Description: fmt.Sprintf("Read MCP resource from %s", params.MCPName),
+					Params:      ReadMCPResourcePermissionsParams(params),
+				},
+			)
+			if err != nil {
+				return fantasy.ToolResponse{}, err
+			}
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			contents, err := mcp.ReadResource(ctx, cfg, params.MCPName, params.URI)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+			if len(contents) == 0 {
+				return fantasy.NewTextResponse(""), nil
+			}
+
+			var textParts []string
+			for _, content := range contents {
+				if content == nil {
+					continue
+				}
+				if content.Text != "" {
+					textParts = append(textParts, content.Text)
+					continue
+				}
+				if len(content.Blob) > 0 {
+					textParts = append(textParts, string(content.Blob))
+					continue
+				}
+				slog.Debug("MCP resource content missing text/blob", "uri", content.URI)
+			}
+
+			if len(textParts) == 0 {
+				return fantasy.NewTextResponse(""), nil
+			}
+
+			return fantasy.NewTextResponse(strings.Join(textParts, "\n")), nil
+		},
+	)
+}

internal/agent/tools/read_mcp_resource.md 🔗

@@ -0,0 +1,20 @@
+Reads a resource from an MCP server and returns its contents.
+
+<when_to_use>
+Use this tool to fetch a specific resource URI exposed by an MCP server.
+</when_to_use>
+
+<usage>
+- Provide MCP server name and resource URI
+- Returns resource text content
+</usage>
+
+<parameters>
+- mcp_name: The MCP server name
+- uri: The resource URI to read
+</parameters>
+
+<notes>
+- Returns text content by concatenating resource parts
+- Binary resources are returned as UTF-8 text when possible
+</notes>

internal/agent/tools/references.go 🔗

@@ -15,7 +15,6 @@ import (
 	"strings"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
@@ -26,7 +25,7 @@ type ReferencesParams struct {
 }
 
 type referencesTool struct {
-	lspClients *csync.Map[string, *lsp.Client]
+	lspManager *lsp.Manager
 }
 
 const ReferencesToolName = "lsp_references"
@@ -34,7 +33,7 @@ const ReferencesToolName = "lsp_references"
 //go:embed references.md
 var referencesDescription []byte
 
-func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		ReferencesToolName,
 		string(referencesDescription),
@@ -43,7 +42,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent
 				return fantasy.NewTextErrorResponse("symbol is required"), nil
 			}
 
-			if lspClients.Len() == 0 {
+			if lspManager.Clients().Len() == 0 {
 				return fantasy.NewTextErrorResponse("no LSP clients available"), nil
 			}
 
@@ -61,7 +60,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent
 			var allLocations []protocol.Location
 			var allErrs error
 			for _, match := range matches {
-				locations, err := find(ctx, lspClients, params.Symbol, match)
+				locations, err := find(ctx, lspManager, params.Symbol, match)
 				if err != nil {
 					if strings.Contains(err.Error(), "no identifier found") {
 						// grep probably matched a comment, string value, or something else that's irrelevant
@@ -91,14 +90,14 @@ func (r *referencesTool) Name() string {
 	return ReferencesToolName
 }
 
-func find(ctx context.Context, lspClients *csync.Map[string, *lsp.Client], symbol string, match grepMatch) ([]protocol.Location, error) {
+func find(ctx context.Context, lspManager *lsp.Manager, symbol string, match grepMatch) ([]protocol.Location, error) {
 	absPath, err := filepath.Abs(match.path)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get absolute path: %s", err)
 	}
 
 	var client *lsp.Client
-	for c := range lspClients.Seq() {
+	for c := range lspManager.Clients().Seq() {
 		if c.HandlesFile(absPath) {
 			client = c
 			break

internal/agent/tools/search.go 🔗

@@ -172,8 +172,8 @@ func getTextContent(n *html.Node) string {
 
 func cleanDuckDuckGoURL(rawURL string) string {
 	if strings.HasPrefix(rawURL, "//duckduckgo.com/l/?uddg=") {
-		if idx := strings.Index(rawURL, "uddg="); idx != -1 {
-			encoded := rawURL[idx+5:]
+		if _, after, ok := strings.Cut(rawURL, "uddg="); ok {
+			encoded := after
 			if ampIdx := strings.Index(encoded, "&"); ampIdx != -1 {
 				encoded = encoded[:ampIdx]
 			}

internal/agent/tools/view.go 🔗

@@ -13,7 +13,6 @@ import (
 	"unicode/utf8"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/lsp"
@@ -48,7 +47,7 @@ const (
 )
 
 func NewViewTool(
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 	permissions permission.Service,
 	filetracker filetracker.Service,
 	workingDir string,
@@ -184,7 +183,7 @@ func NewViewTool(
 				return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
 			}
 
-			notifyLSPs(ctx, lspClients, filePath)
+			notifyLSPs(ctx, lspManager, filePath)
 			output := "<file>\n"
 			// Format the output with line numbers
 			output += addLineNumbers(content, params.Offset+1)
@@ -195,7 +194,7 @@ func NewViewTool(
 					params.Offset+len(strings.Split(content, "\n")))
 			}
 			output += "\n</file>\n"
-			output += getDiagnostics(filePath, lspClients)
+			output += getDiagnostics(filePath, lspManager)
 			filetracker.RecordRead(ctx, sessionID, filePath)
 			return fantasy.WithResponseMetadata(
 				fantasy.NewTextResponse(output),

internal/agent/tools/write.go 🔗

@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"charm.land/fantasy"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
 	"github.com/charmbracelet/crush/internal/filetracker"
@@ -45,7 +44,7 @@ type WriteResponseMetadata struct {
 const WriteToolName = "write"
 
 func NewWriteTool(
-	lspClients *csync.Map[string, *lsp.Client],
+	lspManager *lsp.Manager,
 	permissions permission.Service,
 	files history.Service,
 	filetracker filetracker.Service,
@@ -161,11 +160,11 @@ func NewWriteTool(
 
 			filetracker.RecordRead(ctx, sessionID, filePath)
 
-			notifyLSPs(ctx, lspClients, params.FilePath)
+			notifyLSPs(ctx, lspManager, params.FilePath)
 
 			result := fmt.Sprintf("File successfully written: %s", filePath)
 			result = fmt.Sprintf("<result>\n%s\n</result>", result)
-			result += getDiagnostics(filePath, lspClients)
+			result += getDiagnostics(filePath, lspManager)
 			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
 				WriteResponseMetadata{
 					Diff:      diff,

internal/app/app.go 🔗

@@ -21,8 +21,8 @@ import (
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/format"
 	"github.com/charmbracelet/crush/internal/history"
@@ -33,8 +33,8 @@ import (
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/shell"
-	"github.com/charmbracelet/crush/internal/tui/components/anim"
-	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/update"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/x/ansi"
@@ -58,7 +58,7 @@ type App struct {
 
 	AgentCoordinator agent.Coordinator
 
-	LSPClients *csync.Map[string, *lsp.Client]
+	LSPManager *lsp.Manager
 
 	config *config.Config
 
@@ -69,7 +69,7 @@ type App struct {
 
 	// global context and cleanup functions
 	globalCtx    context.Context
-	cleanupFuncs []func() error
+	cleanupFuncs []func(context.Context) error
 }
 
 // New initializes a new application instance.
@@ -90,7 +90,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 		History:     files,
 		Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
 		FileTracker: filetracker.NewService(q),
-		LSPClients:  csync.NewMap[string, *lsp.Client](),
+		LSPManager:  lsp.NewManager(cfg),
 
 		globalCtx: ctx,
 
@@ -103,16 +103,17 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 
 	app.setupEvents()
 
-	// Initialize LSP clients in the background.
-	go app.initLSPClients(ctx)
-
 	// Check for updates in the background.
 	go app.checkForUpdates(ctx)
 
 	go mcp.Initialize(ctx, app.Permissions, cfg)
 
 	// cleanup database upon app shutdown
-	app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close)
+	app.cleanupFuncs = append(
+		app.cleanupFuncs,
+		func(context.Context) error { return conn.Close() },
+		mcp.Close,
+	)
 
 	// TODO: remove the concept of agent config, most likely.
 	if !cfg.IsConfigured() {
@@ -122,6 +123,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	if err := app.InitCoderAgent(ctx); err != nil {
 		return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
 	}
+
+	// Set up callback for LSP state updates.
+	app.LSPManager.SetCallback(func(name string, client *lsp.Client) {
+		client.SetDiagnosticsCallback(updateLSPDiagnostics)
+		updateLSPState(name, client.GetServerState(), nil, client, 0)
+	})
+
 	return app, nil
 }
 
@@ -160,7 +168,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 	progress = app.config.Options.Progress == nil || *app.config.Options.Progress
 
 	if !hideSpinner && stderrTTY {
-		t := styles.CurrentTheme()
+		t := styles.DefaultStyles()
 
 		// Detect background color to set the appropriate color for the
 		// spinner's 'Generating...' text. Without this, that text would be
@@ -413,7 +421,7 @@ func (app *App) setupEvents() {
 	setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
-	cleanupFunc := func() error {
+	cleanupFunc := func(context.Context) error {
 		cancel()
 		app.serviceEventsWG.Wait()
 		return nil
@@ -421,6 +429,8 @@ func (app *App) setupEvents() {
 	app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc)
 }
 
+const subscriberSendTimeout = 2 * time.Second
+
 func setupSubscriber[T any](
 	ctx context.Context,
 	wg *sync.WaitGroup,
@@ -430,6 +440,10 @@ func setupSubscriber[T any](
 ) {
 	wg.Go(func() {
 		subCh := subscriber(ctx)
+		sendTimer := time.NewTimer(0)
+		<-sendTimer.C
+		defer sendTimer.Stop()
+
 		for {
 			select {
 			case event, ok := <-subCh:
@@ -438,9 +452,17 @@ func setupSubscriber[T any](
 					return
 				}
 				var msg tea.Msg = event
+				if !sendTimer.Stop() {
+					select {
+					case <-sendTimer.C:
+					default:
+					}
+				}
+				sendTimer.Reset(subscriberSendTimeout)
+
 				select {
 				case outputCh <- msg:
-				case <-time.After(2 * time.Second):
+				case <-sendTimer.C:
 					slog.Debug("Message dropped due to slow consumer", "name", name)
 				case <-ctx.Done():
 					slog.Debug("Subscription cancelled", "name", name)
@@ -468,7 +490,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
 		app.Permissions,
 		app.History,
 		app.FileTracker,
-		app.LSPClients,
+		app.LSPManager,
 	)
 	if err != nil {
 		slog.Error("Failed to create coder agent", "err", err)
@@ -486,7 +508,7 @@ func (app *App) Subscribe(program *tea.Program) {
 
 	app.tuiWG.Add(1)
 	tuiCtx, tuiCancel := context.WithCancel(app.globalCtx)
-	app.cleanupFuncs = append(app.cleanupFuncs, func() error {
+	app.cleanupFuncs = append(app.cleanupFuncs, func(context.Context) error {
 		slog.Debug("Cancelling TUI message handler")
 		tuiCancel()
 		app.tuiWG.Wait()
@@ -523,30 +545,30 @@ func (app *App) Shutdown() {
 	// Now run remaining cleanup tasks in parallel.
 	var wg sync.WaitGroup
 
+	// Shared shutdown context for all timeout-bounded cleanup.
+	shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
+	defer cancel()
+
+	// Send exit event
+	wg.Go(func() {
+		event.AppExited()
+	})
+
 	// Kill all background shells.
 	wg.Go(func() {
-		shell.GetBackgroundShellManager().KillAll()
+		shell.GetBackgroundShellManager().KillAll(shutdownCtx)
 	})
 
 	// Shutdown all LSP clients.
-	shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
-	defer cancel()
-	for name, client := range app.LSPClients.Seq2() {
-		wg.Go(func() {
-			if err := client.Close(shutdownCtx); err != nil &&
-				!errors.Is(err, io.EOF) &&
-				!errors.Is(err, context.Canceled) &&
-				err.Error() != "signal: killed" {
-				slog.Warn("Failed to shutdown LSP client", "name", name, "error", err)
-			}
-		})
-	}
+	wg.Go(func() {
+		app.LSPManager.KillAll(shutdownCtx)
+	})
 
 	// Call all cleanup functions.
 	for _, cleanup := range app.cleanupFuncs {
 		if cleanup != nil {
 			wg.Go(func() {
-				if err := cleanup(); err != nil {
+				if err := cleanup(shutdownCtx); err != nil {
 					slog.Error("Failed to cleanup app properly on shutdown", "error", err)
 				}
 			})

internal/app/app_test.go 🔗

@@ -0,0 +1,157 @@
+package app
+
+import (
+	"context"
+	"sync"
+	"testing"
+	"testing/synctest"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+)
+
+func TestSetupSubscriber_NormalFlow(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		f := newSubscriberFixture(t, 10)
+
+		time.Sleep(10 * time.Millisecond)
+		synctest.Wait()
+
+		f.broker.Publish(pubsub.CreatedEvent, "event1")
+		f.broker.Publish(pubsub.CreatedEvent, "event2")
+
+		for range 2 {
+			select {
+			case <-f.outputCh:
+			case <-time.After(5 * time.Second):
+				t.Fatal("Timed out waiting for messages")
+			}
+		}
+
+		f.cancel()
+		f.wg.Wait()
+	})
+}
+
+func TestSetupSubscriber_SlowConsumer(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		f := newSubscriberFixture(t, 0)
+
+		const numEvents = 5
+
+		var pubWg sync.WaitGroup
+		pubWg.Go(func() {
+			for range numEvents {
+				f.broker.Publish(pubsub.CreatedEvent, "event")
+				time.Sleep(10 * time.Millisecond)
+				synctest.Wait()
+			}
+		})
+
+		time.Sleep(time.Duration(numEvents) * (subscriberSendTimeout + 20*time.Millisecond))
+		synctest.Wait()
+
+		received := 0
+		for {
+			select {
+			case <-f.outputCh:
+				received++
+			default:
+				pubWg.Wait()
+				f.cancel()
+				f.wg.Wait()
+				require.Less(t, received, numEvents, "Slow consumer should have dropped some messages")
+				return
+			}
+		}
+	})
+}
+
+func TestSetupSubscriber_ContextCancellation(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		f := newSubscriberFixture(t, 10)
+
+		f.broker.Publish(pubsub.CreatedEvent, "event1")
+		time.Sleep(100 * time.Millisecond)
+		synctest.Wait()
+
+		f.cancel()
+		f.wg.Wait()
+	})
+}
+
+func TestSetupSubscriber_DrainAfterDrop(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		f := newSubscriberFixture(t, 0)
+
+		time.Sleep(10 * time.Millisecond)
+		synctest.Wait()
+
+		// First event: nobody reads outputCh so the timer fires (message dropped).
+		f.broker.Publish(pubsub.CreatedEvent, "event1")
+		time.Sleep(subscriberSendTimeout + 25*time.Millisecond)
+		synctest.Wait()
+
+		// Second event: triggers Stop()==false path; without the fix this deadlocks.
+		f.broker.Publish(pubsub.CreatedEvent, "event2")
+
+		// If the timer drain deadlocks, wg.Wait never returns.
+		done := make(chan struct{})
+		go func() {
+			f.cancel()
+			f.wg.Wait()
+			close(done)
+		}()
+
+		select {
+		case <-done:
+		case <-time.After(5 * time.Second):
+			t.Fatal("setupSubscriber goroutine hung — likely timer drain deadlock")
+		}
+	})
+}
+
+func TestSetupSubscriber_NoTimerLeak(t *testing.T) {
+	defer goleak.VerifyNone(t)
+	synctest.Test(t, func(t *testing.T) {
+		f := newSubscriberFixture(t, 100)
+
+		for range 100 {
+			f.broker.Publish(pubsub.CreatedEvent, "event")
+			time.Sleep(5 * time.Millisecond)
+			synctest.Wait()
+		}
+
+		f.cancel()
+		f.wg.Wait()
+	})
+}
+
+type subscriberFixture struct {
+	broker   *pubsub.Broker[string]
+	wg       sync.WaitGroup
+	outputCh chan tea.Msg
+	cancel   context.CancelFunc
+}
+
+func newSubscriberFixture(t *testing.T, bufSize int) *subscriberFixture {
+	t.Helper()
+	ctx, cancel := context.WithCancel(t.Context())
+	t.Cleanup(cancel)
+
+	f := &subscriberFixture{
+		broker:   pubsub.NewBroker[string](),
+		outputCh: make(chan tea.Msg, bufSize),
+		cancel:   cancel,
+	}
+	t.Cleanup(f.broker.Shutdown)
+
+	setupSubscriber(ctx, &f.wg, "test", func(ctx context.Context) <-chan pubsub.Event[string] {
+		return f.broker.Subscribe(ctx)
+	}, f.outputCh)
+
+	return f
+}

internal/app/lsp.go 🔗

@@ -1,163 +0,0 @@
-package app
-
-import (
-	"cmp"
-	"context"
-	"log/slog"
-	"os/exec"
-	"slices"
-	"sync"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/lsp"
-	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
-)
-
-// initLSPClients initializes LSP clients.
-func (app *App) initLSPClients(ctx context.Context) {
-	slog.Info("LSP clients initialization started")
-
-	manager := powernapconfig.NewManager()
-	manager.LoadDefaults()
-
-	var userConfiguredLSPs []string
-	for name, clientConfig := range app.config.LSP {
-		if clientConfig.Disabled {
-			slog.Info("Skipping disabled LSP client", "name", name)
-			manager.RemoveServer(name)
-			continue
-		}
-
-		// HACK: the user might have the command name in their config, instead
-		// of the actual name. This finds out these cases, and adjusts the name
-		// accordingly.
-		if _, ok := manager.GetServer(name); !ok {
-			for sname, server := range manager.GetServers() {
-				if server.Command == name {
-					name = sname
-					break
-				}
-			}
-		}
-		userConfiguredLSPs = append(userConfiguredLSPs, name)
-		manager.AddServer(name, &powernapconfig.ServerConfig{
-			Command:     clientConfig.Command,
-			Args:        clientConfig.Args,
-			Environment: clientConfig.Env,
-			FileTypes:   clientConfig.FileTypes,
-			RootMarkers: clientConfig.RootMarkers,
-			InitOptions: clientConfig.InitOptions,
-			Settings:    clientConfig.Options,
-		})
-	}
-
-	servers := manager.GetServers()
-	filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
-
-	for _, name := range userConfiguredLSPs {
-		if _, ok := filtered[name]; !ok {
-			updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
-		}
-	}
-
-	var wg sync.WaitGroup
-	for name, server := range filtered {
-		if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) {
-			slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name)
-			continue
-		}
-		wg.Go(func() {
-			app.createAndStartLSPClient(
-				ctx, name,
-				toOurConfig(server, app.config.LSP[name]),
-				slices.Contains(userConfiguredLSPs, name),
-			)
-		})
-	}
-	wg.Wait()
-
-	if app.AgentCoordinator != nil {
-		if err := app.AgentCoordinator.UpdateModels(ctx); err != nil {
-			slog.Error("Failed to refresh tools after LSP startup", "error", err)
-		}
-	}
-}
-
-// toOurConfig merges powernap default config with user config.
-// If user config is zero value, it means no user override exists.
-func toOurConfig(in *powernapconfig.ServerConfig, user config.LSPConfig) config.LSPConfig {
-	return config.LSPConfig{
-		Command:     in.Command,
-		Args:        in.Args,
-		Env:         in.Environment,
-		FileTypes:   in.FileTypes,
-		RootMarkers: in.RootMarkers,
-		InitOptions: in.InitOptions,
-		Options:     in.Settings,
-		Timeout:     user.Timeout,
-	}
-}
-
-// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher.
-func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) {
-	if !userConfigured {
-		if _, err := exec.LookPath(config.Command); err != nil {
-			slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err)
-			return
-		}
-	}
-
-	slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
-
-	// Update state to starting.
-	updateLSPState(name, lsp.StateStarting, nil, nil, 0)
-
-	// Create LSP client.
-	lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
-	if err != nil {
-		if !userConfigured {
-			slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
-			updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
-			return
-		}
-		slog.Error("Failed to create LSP client for", "name", name, "error", err)
-		updateLSPState(name, lsp.StateError, err, nil, 0)
-		return
-	}
-
-	// Set diagnostics callback
-	lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
-
-	// Increase initialization timeout as some servers take more time to start.
-	initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(config.Timeout, 30))*time.Second)
-	defer cancel()
-
-	// Initialize LSP client.
-	_, err = lspClient.Initialize(initCtx, app.config.WorkingDir())
-	if err != nil {
-		slog.Error("LSP client initialization failed", "name", name, "error", err)
-		updateLSPState(name, lsp.StateError, err, lspClient, 0)
-		lspClient.Close(ctx)
-		return
-	}
-
-	// Wait for the server to be ready.
-	if err := lspClient.WaitForServerReady(initCtx); err != nil {
-		slog.Error("Server failed to become ready", "name", name, "error", err)
-		// Server never reached a ready state, but let's continue anyway, as
-		// some functionality might still work.
-		lspClient.SetServerState(lsp.StateError)
-		updateLSPState(name, lsp.StateError, err, lspClient, 0)
-	} else {
-		// Server reached a ready state successfully.
-		slog.Debug("LSP server is ready", "name", name)
-		lspClient.SetServerState(lsp.StateReady)
-		updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
-	}
-
-	slog.Debug("LSP client initialized", "name", name)
-
-	// Add to map with mutex protection before starting goroutine
-	app.LSPClients.Set(name, lspClient)
-}

internal/cmd/dirs_test.go 🔗

@@ -12,6 +12,8 @@ import (
 func init() {
 	os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig")
 	os.Setenv("XDG_DATA_HOME", "/tmp/fakedata")
+	os.Unsetenv("CRUSH_GLOBAL_CONFIG")
+	os.Unsetenv("CRUSH_GLOBAL_DATA")
 }
 
 func TestDirs(t *testing.T) {

internal/cmd/login.go 🔗

@@ -52,17 +52,16 @@ crush login copilot
 		}
 		switch provider {
 		case "hyper":
-			return loginHyper()
+			return loginHyper(app.Config())
 		case "copilot", "github", "github-copilot":
-			return loginCopilot()
+			return loginCopilot(app.Config())
 		default:
 			return fmt.Errorf("unknown platform: %s", args[0])
 		}
 	},
 }
 
-func loginHyper() error {
-	cfg := config.Get()
+func loginHyper(cfg *config.Config) error {
 	if !hyperp.Enabled() {
 		return fmt.Errorf("hyper not enabled")
 	}
@@ -124,10 +123,9 @@ func loginHyper() error {
 	return nil
 }
 
-func loginCopilot() error {
+func loginCopilot(cfg *config.Config) error {
 	ctx := getLoginContext()
 
-	cfg := config.Get()
 	if cfg.HasConfigField("providers.copilot.oauth") {
 		fmt.Println("You are already logged in to GitHub Copilot.")
 		return nil

internal/cmd/logs.go 🔗

@@ -171,8 +171,8 @@ func printLogLine(lineText string) {
 	}
 	msg := data["msg"]
 	level := data["level"]
-	otherData := []any{}
-	keys := []string{}
+	var otherData []any
+	var keys []string
 	for k := range data {
 		keys = append(keys, k)
 	}

internal/cmd/root.go 🔗

@@ -20,7 +20,6 @@ import (
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/projects"
-	"github.com/charmbracelet/crush/internal/tui"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	ui "github.com/charmbracelet/crush/internal/ui/model"
 	"github.com/charmbracelet/crush/internal/version"
@@ -28,14 +27,10 @@ import (
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/charmtone"
-	xstrings "github.com/charmbracelet/x/exp/strings"
 	"github.com/charmbracelet/x/term"
 	"github.com/spf13/cobra"
 )
 
-// kittyTerminals defines terminals supporting querying capabilities.
-var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
-
 func init() {
 	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
@@ -93,27 +88,15 @@ crush -y
 		// Set up the TUI.
 		var env uv.Environ = os.Environ()
 
-		newUI := true
-		if v, err := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); err == nil {
-			newUI = v
-		}
+		com := common.DefaultCommon(app)
+		model := ui.New(com)
 
-		var model tea.Model
-		if newUI {
-			slog.Info("New UI in control!")
-			com := common.DefaultCommon(app)
-			ui := ui.New(com)
-			model = ui
-		} else {
-			ui := tui.New(app)
-			ui.QueryVersion = shouldQueryCapabilities(env)
-			model = ui
-		}
 		program := tea.NewProgram(
 			model,
 			tea.WithEnvironment(env),
 			tea.WithContext(cmd.Context()),
-			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
+			tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
+		)
 		go app.Subscribe(program)
 
 		if _, err := program.Run(); err != nil {
@@ -123,9 +106,6 @@ crush -y
 		}
 		return nil
 	},
-	PostRun: func(cmd *cobra.Command, args []string) {
-		event.AppExited()
-	},
 }
 
 var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
@@ -244,21 +224,21 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
 		return nil, err
 	}
 
-	if shouldEnableMetrics() {
+	if shouldEnableMetrics(cfg) {
 		event.Init()
 	}
 
 	return appInstance, nil
 }
 
-func shouldEnableMetrics() bool {
+func shouldEnableMetrics(cfg *config.Config) bool {
 	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
 		return false
 	}
 	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
 		return false
 	}
-	if config.Get().Options.DisableMetrics {
+	if cfg.Options.DisableMetrics {
 		return false
 	}
 	return true
@@ -313,18 +293,3 @@ func createDotCrushDir(dir string) error {
 
 	return nil
 }
-
-// TODO: Remove me after dropping the old TUI.
-func shouldQueryCapabilities(env uv.Environ) bool {
-	const osVendorTypeApple = "Apple"
-	termType := env.Getenv("TERM")
-	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
-	_, okSSHTTY := env.LookupEnv("SSH_TTY")
-	if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
-		return false
-	}
-	return (!okTermProg && !okSSHTTY) ||
-		(!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
-		// Terminals that do support XTVERSION.
-		xstrings.ContainsAnyOf(termType, kittyTerminals...)
-}

internal/cmd/root_test.go 🔗

@@ -1,160 +0,0 @@
-package cmd
-
-import (
-	"strings"
-	"testing"
-
-	uv "github.com/charmbracelet/ultraviolet"
-	xstrings "github.com/charmbracelet/x/exp/strings"
-	"github.com/stretchr/testify/require"
-)
-
-type mockEnviron []string
-
-func (m mockEnviron) Getenv(key string) string {
-	v, _ := m.LookupEnv(key)
-	return v
-}
-
-func (m mockEnviron) LookupEnv(key string) (string, bool) {
-	for _, env := range m {
-		kv := strings.SplitN(env, "=", 2)
-		if len(kv) == 2 && kv[0] == key {
-			return kv[1], true
-		}
-	}
-	return "", false
-}
-
-func (m mockEnviron) ExpandEnv(s string) string {
-	return s // Not implemented for tests
-}
-
-func (m mockEnviron) Slice() []string {
-	return []string(m)
-}
-
-func TestShouldQueryImageCapabilities(t *testing.T) {
-	t.Parallel()
-
-	tests := []struct {
-		name string
-		env  mockEnviron
-		want bool
-	}{
-		{
-			name: "kitty terminal",
-			env:  mockEnviron{"TERM=xterm-kitty"},
-			want: true,
-		},
-		{
-			name: "wezterm terminal",
-			env:  mockEnviron{"TERM=xterm-256color"},
-			want: true,
-		},
-		{
-			name: "wezterm with WEZTERM env",
-			env:  mockEnviron{"TERM=xterm-256color", "WEZTERM_EXECUTABLE=/Applications/WezTerm.app/Contents/MacOS/wezterm-gui"},
-			want: true, // Not detected via TERM, only via stringext.ContainsAny which checks TERM
-		},
-		{
-			name: "Apple Terminal",
-			env:  mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-256color"},
-			want: false,
-		},
-		{
-			name: "alacritty",
-			env:  mockEnviron{"TERM=alacritty"},
-			want: true,
-		},
-		{
-			name: "ghostty",
-			env:  mockEnviron{"TERM=xterm-ghostty"},
-			want: true,
-		},
-		{
-			name: "rio",
-			env:  mockEnviron{"TERM=rio"},
-			want: true,
-		},
-		{
-			name: "wezterm (detected via TERM)",
-			env:  mockEnviron{"TERM=wezterm"},
-			want: true,
-		},
-		{
-			name: "SSH session",
-			env:  mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-256color"},
-			want: false,
-		},
-		{
-			name: "generic terminal",
-			env:  mockEnviron{"TERM=xterm-256color"},
-			want: true,
-		},
-		{
-			name: "kitty over SSH",
-			env:  mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-kitty"},
-			want: true,
-		},
-		{
-			name: "Apple Terminal with kitty TERM (should still be false due to TERM_PROGRAM)",
-			env:  mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-kitty"},
-			want: false,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-			got := shouldQueryCapabilities(uv.Environ(tt.env))
-			require.Equal(t, tt.want, got, "shouldQueryImageCapabilities() = %v, want %v", got, tt.want)
-		})
-	}
-}
-
-// This is a helper to test the underlying logic of stringext.ContainsAny
-// which is used by shouldQueryImageCapabilities
-func TestStringextContainsAny(t *testing.T) {
-	t.Parallel()
-
-	tests := []struct {
-		name   string
-		s      string
-		substr []string
-		want   bool
-	}{
-		{
-			name:   "kitty in TERM",
-			s:      "xterm-kitty",
-			substr: kittyTerminals,
-			want:   true,
-		},
-		{
-			name:   "wezterm in TERM",
-			s:      "wezterm",
-			substr: kittyTerminals,
-			want:   true,
-		},
-		{
-			name:   "alacritty in TERM",
-			s:      "alacritty",
-			substr: kittyTerminals,
-			want:   true,
-		},
-		{
-			name:   "generic terminal not in list",
-			s:      "xterm-256color",
-			substr: kittyTerminals,
-			want:   false,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-			got := xstrings.ContainsAnyOf(tt.s, tt.substr...)
-			require.Equal(t, tt.want, got)
-		})
-	}
-}

internal/commands/commands.go 🔗

@@ -227,9 +227,9 @@ func isMarkdownFile(name string) bool {
 	return strings.HasSuffix(strings.ToLower(name), ".md")
 }
 
-func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
+func GetMCPPrompt(cfg *config.Config, clientID, promptID string, args map[string]string) (string, error) {
 	// TODO: we should pass the context down
-	result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args)
+	result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args)
 	if err != nil {
 		return "", err
 	}

internal/config/agent_id_test.go 🔗

@@ -0,0 +1,29 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestConfig_AgentIDs(t *testing.T) {
+	cfg := &Config{
+		Options: &Options{
+			DisabledTools: []string{},
+		},
+	}
+	cfg.SetupAgents()
+
+	t.Run("Coder agent should have correct ID", func(t *testing.T) {
+		coderAgent, ok := cfg.Agents[AgentCoder]
+		require.True(t, ok)
+		assert.Equal(t, AgentCoder, coderAgent.ID, "Coder agent ID should be '%s'", AgentCoder)
+	})
+
+	t.Run("Task agent should have correct ID", func(t *testing.T) {
+		taskAgent, ok := cfg.Agents[AgentTask]
+		require.True(t, ok)
+		assert.Equal(t, AgentTask, taskAgent.ID, "Task agent ID should be '%s'", AgentTask)
+	})
+}

internal/config/config.go 🔗

@@ -435,7 +435,7 @@ type Agent struct {
 }
 
 type Tools struct {
-	Ls ToolLs `json:"ls,omitempty"`
+	Ls ToolLs `json:"ls,omitzero"`
 }
 
 func (o Tools) merge(t Tools) Tools {
@@ -474,7 +474,7 @@ type Config struct {
 
 	Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
 
-	Tools Tools `json:"tools,omitempty" jsonschema:"description=Tool configurations"`
+	Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
 
 	Agents map[string]Agent `json:"-"`
 
@@ -847,6 +847,8 @@ func allToolNames() []string {
 		"todos",
 		"view",
 		"write",
+		"list_mcp_resources",
+		"read_mcp_resource",
 	}
 }
 
@@ -865,7 +867,7 @@ func resolveReadOnlyTools(tools []string) []string {
 }
 
 func filterSlice(data []string, mask []string, include bool) []string {
-	filtered := []string{}
+	var filtered []string
 	for _, s := range data {
 		// if include is true, we include items that ARE in the mask
 		// if include is false, we include items that are NOT in the mask
@@ -890,7 +892,7 @@ func (c *Config) SetupAgents() {
 		},
 
 		AgentTask: {
-			ID:           AgentCoder,
+			ID:           AgentTask,
 			Name:         "Task",
 			Description:  "An agent that helps with searching for context and finding implementation details.",
 			Model:        SelectedModelTypeLarge,
@@ -908,42 +910,58 @@ func (c *Config) Resolver() VariableResolver {
 }
 
 func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
-	testURL := ""
-	headers := make(map[string]string)
-	apiKey, _ := resolver.ResolveValue(c.APIKey)
+	var (
+		providerID = catwalk.InferenceProvider(c.ID)
+		testURL    = ""
+		headers    = make(map[string]string)
+		apiKey, _  = resolver.ResolveValue(c.APIKey)
+	)
+
+	switch providerID {
+	case catwalk.InferenceProviderMiniMax:
+		// NOTE: MiniMax has no good endpoint we can use to validate the API key.
+		// Let's at least check the pattern.
+		if !strings.HasPrefix(apiKey, "sk-") {
+			return fmt.Errorf("invalid API key format for provider %s", c.ID)
+		}
+		return nil
+	}
+
 	switch c.Type {
 	case catwalk.TypeOpenAI, catwalk.TypeOpenAICompat, catwalk.TypeOpenRouter:
 		baseURL, _ := resolver.ResolveValue(c.BaseURL)
-		if baseURL == "" {
-			baseURL = "https://api.openai.com/v1"
-		}
-		if c.ID == string(catwalk.InferenceProviderOpenRouter) {
+		baseURL = cmp.Or(baseURL, "https://api.openai.com/v1")
+
+		switch providerID {
+		case catwalk.InferenceProviderOpenRouter:
 			testURL = baseURL + "/credits"
-		} else {
+		default:
 			testURL = baseURL + "/models"
 		}
+
 		headers["Authorization"] = "Bearer " + apiKey
 	case catwalk.TypeAnthropic:
 		baseURL, _ := resolver.ResolveValue(c.BaseURL)
-		if baseURL == "" {
-			baseURL = "https://api.anthropic.com/v1"
-		}
-		testURL = baseURL + "/models"
-		// TODO: replace with const when catwalk is released
-		if c.ID == "kimi-coding" {
+		baseURL = cmp.Or(baseURL, "https://api.anthropic.com/v1")
+
+		switch providerID {
+		case catwalk.InferenceKimiCoding:
 			testURL = baseURL + "/v1/models"
+		default:
+			testURL = baseURL + "/models"
 		}
+
 		headers["x-api-key"] = apiKey
 		headers["anthropic-version"] = "2023-06-01"
 	case catwalk.TypeGoogle:
 		baseURL, _ := resolver.ResolveValue(c.BaseURL)
-		if baseURL == "" {
-			baseURL = "https://generativelanguage.googleapis.com"
-		}
+		baseURL = cmp.Or(baseURL, "https://generativelanguage.googleapis.com")
 		testURL = baseURL + "/v1beta/models?key=" + url.QueryEscape(apiKey)
 	}
+
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
+
 	client := &http.Client{}
 	req, err := http.NewRequestWithContext(ctx, "GET", testURL, nil)
 	if err != nil {
@@ -955,17 +973,19 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 	for k, v := range c.ExtraHeaders {
 		req.Header.Set(k, v)
 	}
+
 	resp, err := client.Do(req)
 	if err != nil {
 		return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err)
 	}
 	defer resp.Body.Close()
-	if c.ID == string(catwalk.InferenceProviderZAI) {
+
+	switch providerID {
+	case catwalk.InferenceProviderZAI:
 		if resp.StatusCode == http.StatusUnauthorized {
-			// For z.ai just check if the http response is not 401.
 			return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status)
 		}
-	} else {
+	default:
 		if resp.StatusCode != http.StatusOK {
 			return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status)
 		}

internal/config/init.go 🔗

@@ -6,7 +6,6 @@ import (
 	"path/filepath"
 	"slices"
 	"strings"
-	"sync/atomic"
 
 	"github.com/charmbracelet/crush/internal/fsext"
 )
@@ -19,25 +18,15 @@ type ProjectInitFlag struct {
 	Initialized bool `json:"initialized"`
 }
 
-// TODO: we need to remove the global config instance keeping it now just until everything is migrated
-var instance atomic.Pointer[Config]
-
 func Init(workingDir, dataDir string, debug bool) (*Config, error) {
 	cfg, err := Load(workingDir, dataDir, debug)
 	if err != nil {
 		return nil, err
 	}
-	instance.Store(cfg)
-	return instance.Load(), nil
-}
-
-func Get() *Config {
-	cfg := instance.Load()
-	return cfg
+	return cfg, nil
 }
 
-func ProjectNeedsInitialization() (bool, error) {
-	cfg := Get()
+func ProjectNeedsInitialization(cfg *Config) (bool, error) {
 	if cfg == nil {
 		return false, fmt.Errorf("config not loaded")
 	}
@@ -110,8 +99,7 @@ func dirHasNoVisibleFiles(dir string) (bool, error) {
 	return len(files) == 0, nil
 }
 
-func MarkProjectInitialized() error {
-	cfg := Get()
+func MarkProjectInitialized(cfg *Config) error {
 	if cfg == nil {
 		return fmt.Errorf("config not loaded")
 	}
@@ -126,10 +114,13 @@ func MarkProjectInitialized() error {
 	return nil
 }
 
-func HasInitialDataConfig() bool {
+func HasInitialDataConfig(cfg *Config) bool {
+	if cfg == nil {
+		return false
+	}
 	cfgPath := GlobalConfigData()
 	if _, err := os.Stat(cfgPath); err != nil {
 		return false
 	}
-	return Get().IsConfigured()
+	return cfg.IsConfigured()
 }

internal/config/load.go 🔗

@@ -94,7 +94,7 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
 }
 
 func PushPopCrushEnv() func() {
-	found := []string{}
+	var found []string
 	for _, ev := range os.Environ() {
 		if strings.HasPrefix(ev, "CRUSH_") {
 			pair := strings.SplitN(ev, "=", 2)
@@ -330,6 +330,11 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 
 		c.Providers.Set(id, providerConfig)
 	}
+
+	if c.Providers.Len() == 0 && c.Options.DisableDefaultProviders {
+		return fmt.Errorf("default providers are disabled and there are no custom providers are configured")
+	}
+
 	return nil
 }
 
@@ -341,12 +346,6 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if c.Options.TUI == nil {
 		c.Options.TUI = &TUIOptions{}
 	}
-	if c.Options.ContextPaths == nil {
-		c.Options.ContextPaths = []string{}
-	}
-	if c.Options.SkillsPaths == nil {
-		c.Options.SkillsPaths = []string{}
-	}
 	if dataDir != "" {
 		c.Options.DataDirectory = dataDir
 	} else if c.Options.DataDirectory == "" {

internal/config/load_test.go 🔗

@@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
 
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
@@ -509,11 +509,11 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
-	assert.Equal(t, []string{}, taskAgent.AllowedTools)
+	assert.Len(t, taskAgent.AllowedTools, 0)
 }
 
 func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
@@ -1127,7 +1127,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
 		})
 		resolver := NewEnvironmentVariableResolver(env)
 		err := cfg.configureProviders(env, resolver, knownProviders)
-		require.NoError(t, err)
+		require.ErrorContains(t, err, "no custom providers")
 
 		// openai should NOT be present because it lacks base_url and models.
 		require.Equal(t, 0, cfg.Providers.Len())
@@ -1252,7 +1252,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
 		env := env.NewFromMap(map[string]string{})
 		resolver := NewEnvironmentVariableResolver(env)
 		err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
-		require.NoError(t, err)
+		require.ErrorContains(t, err, "no custom providers")
 
 		// Provider should be rejected for missing models.
 		require.Equal(t, 0, cfg.Providers.Len())
@@ -1276,7 +1276,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
 		env := env.NewFromMap(map[string]string{})
 		resolver := NewEnvironmentVariableResolver(env)
 		err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
-		require.NoError(t, err)
+		require.ErrorContains(t, err, "no custom providers")
 
 		// Provider should be rejected for missing base_url.
 		require.Equal(t, 0, cfg.Providers.Len())

internal/csync/value_test.go 🔗

@@ -83,11 +83,9 @@ func TestValue_ConcurrentAccess(t *testing.T) {
 
 	// Concurrent readers.
 	for range 100 {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
+		wg.Go(func() {
 			_ = v.Get()
-		}()
+		})
 	}
 
 	wg.Wait()

internal/db/connect.go 🔗

@@ -10,6 +10,16 @@ import (
 	"github.com/pressly/goose/v3"
 )
 
+var pragmas = map[string]string{
+	"foreign_keys":  "ON",
+	"journal_mode":  "WAL",
+	"page_size":     "4096",
+	"cache_size":    "-8000",
+	"synchronous":   "NORMAL",
+	"secure_delete": "ON",
+	"busy_timeout":  "30000",
+}
+
 // Connect opens a SQLite database connection and runs migrations.
 func Connect(ctx context.Context, dataDir string) (*sql.DB, error) {
 	if dataDir == "" {

internal/db/connect_modernc.go 🔗

@@ -14,18 +14,15 @@ func openDB(dbPath string) (*sql.DB, error) {
 	// Set pragmas for better performance via _pragma query params.
 	// Format: _pragma=name(value)
 	params := url.Values{}
-	params.Add("_pragma", "foreign_keys(on)")
-	params.Add("_pragma", "journal_mode(WAL)")
-	params.Add("_pragma", "page_size(4096)")
-	params.Add("_pragma", "cache_size(-8000)")
-	params.Add("_pragma", "synchronous(NORMAL)")
-	params.Add("_pragma", "secure_delete(on)")
-	params.Add("_pragma", "busy_timeout(5000)")
+	for name, value := range pragmas {
+		params.Add("_pragma", fmt.Sprintf("%s(%s)", name, value))
+	}
 
 	dsn := fmt.Sprintf("file:%s?%s", dbPath, params.Encode())
 	db, err := sql.Open("sqlite", dsn)
 	if err != nil {
 		return nil, fmt.Errorf("failed to open database: %w", err)
 	}
+
 	return db, nil
 }

internal/db/connect_ncruces.go 🔗

@@ -12,21 +12,12 @@ import (
 )
 
 func openDB(dbPath string) (*sql.DB, error) {
-	// Set pragmas for better performance.
-	pragmas := []string{
-		"PRAGMA foreign_keys = ON;",
-		"PRAGMA journal_mode = WAL;",
-		"PRAGMA page_size = 4096;",
-		"PRAGMA cache_size = -8000;",
-		"PRAGMA synchronous = NORMAL;",
-		"PRAGMA secure_delete = ON;",
-		"PRAGMA busy_timeout = 5000;",
-	}
-
 	db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error {
-		for _, pragma := range pragmas {
-			if err := c.Exec(pragma); err != nil {
-				return fmt.Errorf("failed to set pragma %q: %w", pragma, err)
+		// Set pragmas for better performance via _pragma query params.
+		// Format: PRAGMA name = value;
+		for name, value := range pragmas {
+			if err := c.Exec(fmt.Sprintf("PRAGMA %s = %s;", name, value)); err != nil {
+				return fmt.Errorf("failed to set pragma %q: %w", name, err)
 			}
 		}
 		return nil
@@ -34,5 +25,6 @@ func openDB(dbPath string) (*sql.DB, error) {
 	if err != nil {
 		return nil, fmt.Errorf("failed to open database: %w", err)
 	}
+
 	return db, nil
 }

internal/db/db.go 🔗

@@ -108,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
 		return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
 	}
+	if q.listSessionReadFilesStmt, err = db.PrepareContext(ctx, listSessionReadFiles); err != nil {
+		return nil, fmt.Errorf("error preparing query ListSessionReadFiles: %w", err)
+	}
 	if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
 		return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
 	}
@@ -271,6 +274,11 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
 		}
 	}
+	if q.listSessionReadFilesStmt != nil {
+		if cerr := q.listSessionReadFilesStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing listSessionReadFilesStmt: %w", cerr)
+		}
+	}
 	if q.listSessionsStmt != nil {
 		if cerr := q.listSessionsStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
@@ -368,6 +376,7 @@ type Queries struct {
 	listLatestSessionFilesStmt     *sql.Stmt
 	listMessagesBySessionStmt      *sql.Stmt
 	listNewFilesStmt               *sql.Stmt
+	listSessionReadFilesStmt       *sql.Stmt
 	listSessionsStmt               *sql.Stmt
 	listUserMessagesBySessionStmt  *sql.Stmt
 	recordFileReadStmt             *sql.Stmt
@@ -408,6 +417,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 		listLatestSessionFilesStmt:     q.listLatestSessionFilesStmt,
 		listMessagesBySessionStmt:      q.listMessagesBySessionStmt,
 		listNewFilesStmt:               q.listNewFilesStmt,
+		listSessionReadFilesStmt:       q.listSessionReadFilesStmt,
 		listSessionsStmt:               q.listSessionsStmt,
 		listUserMessagesBySessionStmt:  q.listUserMessagesBySessionStmt,
 		recordFileReadStmt:             q.recordFileReadStmt,

internal/db/querier.go 🔗

@@ -37,6 +37,7 @@ type Querier interface {
 	ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
 	ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
 	ListNewFiles(ctx context.Context) ([]File, error)
+	ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error)
 	ListSessions(ctx context.Context) ([]Session, error)
 	ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
 	RecordFileRead(ctx context.Context, arg RecordFileReadParams) error

internal/db/read_files.sql.go 🔗

@@ -48,6 +48,39 @@ type RecordFileReadParams struct {
 	Path      string `json:"path"`
 }
 
+const listSessionReadFiles = `-- name: ListSessionReadFiles :many
+SELECT session_id, path, read_at FROM read_files
+WHERE session_id = ?
+ORDER BY read_at DESC
+`
+
+func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error) {
+	rows, err := q.query(ctx, q.listSessionReadFilesStmt, listSessionReadFiles, sessionID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []ReadFile{}
+	for rows.Next() {
+		var i ReadFile
+		if err := rows.Scan(
+			&i.SessionID,
+			&i.Path,
+			&i.ReadAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
 	_, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
 		arg.SessionID,

internal/db/sql/read_files.sql 🔗

@@ -13,3 +13,8 @@ INSERT INTO read_files (
 -- name: GetFileRead :one
 SELECT * FROM read_files
 WHERE session_id = ? AND path = ? LIMIT 1;
+
+-- name: ListSessionReadFiles :many
+SELECT * FROM read_files
+WHERE session_id = ?
+ORDER BY read_at DESC;

internal/event/event.go 🔗

@@ -39,8 +39,9 @@ func SetNonInteractive(nonInteractive bool) {
 
 func Init() {
 	c, err := posthog.NewWithConfig(key, posthog.Config{
-		Endpoint: endpoint,
-		Logger:   logger{},
+		Endpoint:        endpoint,
+		Logger:          logger{},
+		ShutdownTimeout: 500 * time.Millisecond,
 	})
 	if err != nil {
 		slog.Error("Failed to initialize PostHog client", "error", err)

internal/filetracker/service.go 🔗

@@ -3,6 +3,7 @@ package filetracker
 
 import (
 	"context"
+	"fmt"
 	"log/slog"
 	"os"
 	"path/filepath"
@@ -19,6 +20,9 @@ type Service interface {
 	// LastReadTime returns when a file was last read.
 	// Returns zero time if never read.
 	LastReadTime(ctx context.Context, sessionID, path string) time.Time
+
+	// ListReadFiles returns the paths of all files read in a session.
+	ListReadFiles(ctx context.Context, sessionID string) ([]string, error)
 }
 
 type service struct {
@@ -68,3 +72,22 @@ func relpath(path string) string {
 	}
 	return relpath
 }
+
+// ListReadFiles returns the paths of all files read in a session.
+func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
+	readFiles, err := s.q.ListSessionReadFiles(ctx, sessionID)
+	if err != nil {
+		return nil, fmt.Errorf("listing read files: %w", err)
+	}
+
+	basepath, err := os.Getwd()
+	if err != nil {
+		return nil, fmt.Errorf("getting working directory: %w", err)
+	}
+
+	paths := make([]string, 0, len(readFiles))
+	for _, rf := range readFiles {
+		paths = append(paths, filepath.Join(basepath, rf.Path))
+	}
+	return paths, nil
+}

internal/format/spinner.go 🔗

@@ -7,7 +7,7 @@ import (
 	"os"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/anim"
+	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -22,8 +22,8 @@ type model struct {
 	anim   *anim.Anim
 }
 
-func (m model) Init() tea.Cmd  { return m.anim.Init() }
-func (m model) View() tea.View { return tea.NewView(m.anim.View()) }
+func (m model) Init() tea.Cmd  { return m.anim.Start() }
+func (m model) View() tea.View { return tea.NewView(m.anim.Render()) }
 
 // Update implements tea.Model.
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -34,10 +34,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.cancel()
 			return m, tea.Quit
 		}
+	case anim.StepMsg:
+		cmd := m.anim.Animate(msg)
+		return m, cmd
 	}
-	mm, cmd := m.anim.Update(msg)
-	m.anim = mm.(*anim.Anim)
-	return m, cmd
+	return m, nil
 }
 
 // NewSpinner creates a new spinner with the given message

internal/fsext/paste.go 🔗

@@ -1,20 +1,36 @@
 package fsext
 
 import (
-	"runtime"
+	"os"
 	"strings"
 )
 
-func PasteStringToPaths(s string) []string {
-	switch runtime.GOOS {
-	case "windows":
-		return windowsPasteStringToPaths(s)
+func ParsePastedFiles(s string) []string {
+	s = strings.TrimSpace(s)
+
+	// NOTE: Rio on Windows adds NULL chars for some reason.
+	s = strings.ReplaceAll(s, "\x00", "")
+
+	switch {
+	case attemptStat(s):
+		return strings.Split(s, "\n")
+	case os.Getenv("WT_SESSION") != "":
+		return windowsTerminalParsePastedFiles(s)
 	default:
-		return unixPasteStringToPaths(s)
+		return unixParsePastedFiles(s)
+	}
+}
+
+func attemptStat(s string) bool {
+	for path := range strings.SplitSeq(s, "\n") {
+		if info, err := os.Stat(path); err != nil || info.IsDir() {
+			return false
+		}
 	}
+	return true
 }
 
-func windowsPasteStringToPaths(s string) []string {
+func windowsTerminalParsePastedFiles(s string) []string {
 	if strings.TrimSpace(s) == "" {
 		return nil
 	}
@@ -42,8 +58,10 @@ func windowsPasteStringToPaths(s string) []string {
 			}
 		case inQuotes:
 			current.WriteByte(ch)
+		case ch != ' ':
+			// Text outside quotes is not allowed
+			return nil
 		}
-		// Skip characters outside quotes and spaces between quoted sections
 	}
 
 	// Add any remaining content if quotes were properly closed
@@ -59,7 +77,7 @@ func windowsPasteStringToPaths(s string) []string {
 	return paths
 }
 
-func unixPasteStringToPaths(s string) []string {
+func unixParsePastedFiles(s string) []string {
 	if strings.TrimSpace(s) == "" {
 		return nil
 	}

internal/fsext/paste_test.go 🔗

@@ -6,8 +6,8 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestPasteStringToPaths(t *testing.T) {
-	t.Run("Windows", func(t *testing.T) {
+func TestParsePastedFiles(t *testing.T) {
+	t.Run("WindowsTerminal", func(t *testing.T) {
 		tests := []struct {
 			name     string
 			input    string
@@ -24,7 +24,7 @@ func TestPasteStringToPaths(t *testing.T) {
 				expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`},
 			},
 			{
-				name:     "sigle with spaces",
+				name:     "single with spaces",
 				input:    `"C:\path\my screenshot one.png"`,
 				expected: []string{`C:\path\my screenshot one.png`},
 			},
@@ -46,7 +46,7 @@ func TestPasteStringToPaths(t *testing.T) {
 			{
 				name:     "text outside quotes",
 				input:    `"C:\path\file.png" some random text "C:\path\file2.png"`,
-				expected: []string{`C:\path\file.png`, `C:\path\file2.png`},
+				expected: nil,
 			},
 			{
 				name:     "multiple spaces between paths",
@@ -66,7 +66,7 @@ func TestPasteStringToPaths(t *testing.T) {
 		}
 		for _, tt := range tests {
 			t.Run(tt.name, func(t *testing.T) {
-				result := windowsPasteStringToPaths(tt.input)
+				result := windowsTerminalParsePastedFiles(tt.input)
 				require.Equal(t, tt.expected, result)
 			})
 		}
@@ -141,7 +141,7 @@ func TestPasteStringToPaths(t *testing.T) {
 		}
 		for _, tt := range tests {
 			t.Run(tt.name, func(t *testing.T) {
-				result := unixPasteStringToPaths(tt.input)
+				result := unixParsePastedFiles(tt.input)
 				require.Equal(t, tt.expected, result)
 			})
 		}

internal/lsp/client.go 🔗

@@ -8,17 +8,14 @@ import (
 	"maps"
 	"os"
 	"path/filepath"
-	"strings"
 	"sync"
 	"sync/atomic"
 	"time"
 
-	"github.com/bmatcuk/doublestar/v4"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/home"
-	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
 	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 	"github.com/charmbracelet/x/powernap/pkg/transport"
@@ -35,9 +32,10 @@ type DiagnosticCounts struct {
 type Client struct {
 	client *powernap.Client
 	name   string
+	debug  bool
 
 	// Working directory this LSP is scoped to.
-	workDir string
+	cwd string
 
 	// File types this LSP server handles (e.g., .go, .rs, .py)
 	fileTypes []string
@@ -68,7 +66,14 @@ type Client struct {
 }
 
 // New creates a new LSP client using the powernap implementation.
-func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config.VariableResolver) (*Client, error) {
+func New(
+	ctx context.Context,
+	name string,
+	cfg config.LSPConfig,
+	resolver config.VariableResolver,
+	cwd string,
+	debug bool,
+) (*Client, error) {
 	client := &Client{
 		name:        name,
 		fileTypes:   cfg.FileTypes,
@@ -76,7 +81,9 @@ func New(ctx context.Context, name string, cfg config.LSPConfig, resolver config
 		openFiles:   csync.NewMap[string, *OpenFileInfo](),
 		config:      cfg,
 		ctx:         ctx,
+		debug:       debug,
 		resolver:    resolver,
+		cwd:         cwd,
 	}
 	client.serverState.Store(StateStarting)
 
@@ -118,7 +125,10 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol
 	return result, nil
 }
 
-// Close closes the LSP client.
+// Kill kills the client without doing anything else.
+func (c *Client) Kill() { c.client.Kill() }
+
+// Close closes all open files in the client, then the client.
 func (c *Client) Close(ctx context.Context) error {
 	c.CloseAllFiles(ctx)
 
@@ -132,13 +142,7 @@ func (c *Client) Close(ctx context.Context) error {
 
 // createPowernapClient creates a new powernap client with the current configuration.
 func (c *Client) createPowernapClient() error {
-	workDir, err := os.Getwd()
-	if err != nil {
-		return fmt.Errorf("failed to get working directory: %w", err)
-	}
-
-	rootURI := string(protocol.URIFromPath(workDir))
-	c.workDir = workDir
+	rootURI := string(protocol.URIFromPath(c.cwd))
 
 	command, err := c.resolver.ResolveValue(c.config.Command)
 	if err != nil {
@@ -155,7 +159,7 @@ func (c *Client) createPowernapClient() error {
 		WorkspaceFolders: []protocol.WorkspaceFolder{
 			{
 				URI:  rootURI,
-				Name: filepath.Base(workDir),
+				Name: filepath.Base(c.cwd),
 			},
 		},
 	}
@@ -174,7 +178,11 @@ func (c *Client) registerHandlers() {
 	c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
 	c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
 	c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
-	c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
+	c.RegisterNotificationHandler("window/showMessage", func(ctx context.Context, method string, params json.RawMessage) {
+		if c.debug {
+			HandleServerMessage(ctx, method, params)
+		}
+	})
 	c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(_ context.Context, _ string, params json.RawMessage) {
 		HandleDiagnostics(c, params)
 	})
@@ -194,6 +202,8 @@ func (c *Client) Restart() error {
 		slog.Warn("Error closing client during restart", "name", c.name, "error", err)
 	}
 
+	c.SetServerState(StateStopped)
+
 	c.diagCountsCache = DiagnosticCounts{}
 	c.diagCountsVersion = 0
 
@@ -231,7 +241,8 @@ func (c *Client) Restart() error {
 type ServerState int
 
 const (
-	StateStarting ServerState = iota
+	StateStopped ServerState = iota
+	StateStarting
 	StateReady
 	StateError
 	StateDisabled
@@ -262,8 +273,6 @@ func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
 
 // WaitForServerReady waits for the server to be ready
 func (c *Client) WaitForServerReady(ctx context.Context) error {
-	cfg := config.Get()
-
 	// Set initial state
 	c.SetServerState(StateStarting)
 
@@ -275,7 +284,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
 	ticker := time.NewTicker(500 * time.Millisecond)
 	defer ticker.Stop()
 
-	if cfg != nil && cfg.Options.DebugLSP {
+	if c.debug {
 		slog.Debug("Waiting for LSP server to be ready...")
 	}
 
@@ -289,7 +298,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
 		case <-ticker.C:
 			// Check if client is running
 			if !c.client.IsRunning() {
-				if cfg != nil && cfg.Options.DebugLSP {
+				if c.debug {
 					slog.Debug("LSP server not ready yet", "server", c.name)
 				}
 				continue
@@ -297,7 +306,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
 
 			// Server is ready
 			c.SetServerState(StateReady)
-			if cfg != nil && cfg.Options.DebugLSP {
+			if c.debug {
 				slog.Debug("LSP server is ready")
 			}
 			return nil
@@ -314,37 +323,11 @@ type OpenFileInfo struct {
 // HandlesFile checks if this LSP client handles the given file based on its
 // extension and whether it's within the working directory.
 func (c *Client) HandlesFile(path string) bool {
-	// Check if file is within working directory.
-	absPath, err := filepath.Abs(path)
-	if err != nil {
-		slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
-		return false
-	}
-	relPath, err := filepath.Rel(c.workDir, absPath)
-	if err != nil || strings.HasPrefix(relPath, "..") {
-		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
+	if !fsext.HasPrefix(path, c.cwd) {
+		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.cwd)
 		return false
 	}
-
-	// If no file types are specified, handle all files (backward compatibility).
-	if len(c.fileTypes) == 0 {
-		return true
-	}
-
-	kind := powernap.DetectLanguage(path)
-	name := strings.ToLower(filepath.Base(path))
-	for _, filetype := range c.fileTypes {
-		suffix := strings.ToLower(filetype)
-		if !strings.HasPrefix(suffix, ".") {
-			suffix = "." + suffix
-		}
-		if strings.HasSuffix(name, suffix) || filetype == string(kind) {
-			slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
-			return true
-		}
-	}
-	slog.Debug("Doesn't handle file", "name", c.name, "file", name)
-	return false
+	return handlesFiletype(c.name, c.fileTypes, path)
 }
 
 // OpenFile opens a file in the LSP server.
@@ -416,10 +399,8 @@ func (c *Client) IsFileOpen(filepath string) bool {
 
 // CloseAllFiles closes all currently open files.
 func (c *Client) CloseAllFiles(ctx context.Context) {
-	cfg := config.Get()
-	debugLSP := cfg != nil && cfg.Options.DebugLSP
 	for uri := range c.openFiles.Seq2() {
-		if debugLSP {
+		if c.debug {
 			slog.Debug("Closing file", "file", uri)
 		}
 		if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil {
@@ -486,31 +467,6 @@ func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
 	return c.OpenFile(ctx, filepath)
 }
 
-// GetDiagnosticsForFile ensures a file is open and returns its diagnostics.
-func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
-	documentURI := protocol.URIFromPath(filepath)
-
-	// Make sure the file is open
-	if !c.IsFileOpen(filepath) {
-		if err := c.OpenFile(ctx, filepath); err != nil {
-			return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
-		}
-
-		// Give the LSP server a moment to process the file
-		time.Sleep(100 * time.Millisecond)
-	}
-
-	// Get diagnostics
-	diagnostics, _ := c.diagnostics.Get(documentURI)
-
-	return diagnostics, nil
-}
-
-// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache.
-func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentURI) {
-	c.diagnostics.Del(uri)
-}
-
 // RegisterNotificationHandler registers a notification handler.
 func (c *Client) RegisterNotificationHandler(method string, handler transport.NotificationHandler) {
 	c.client.RegisterNotificationHandler(method, handler)
@@ -521,11 +477,6 @@ func (c *Client) RegisterServerRequestHandler(method string, handler transport.H
 	c.client.RegisterHandler(method, handler)
 }
 
-// DidChangeWatchedFiles sends a workspace/didChangeWatchedFiles notification to the server.
-func (c *Client) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error {
-	return c.client.NotifyDidChangeWatchedFiles(ctx, params.Changes)
-}
-
 // openKeyConfigFiles opens important configuration files that help initialize the server.
 func (c *Client) openKeyConfigFiles(ctx context.Context) {
 	wd, err := os.Getwd()
@@ -576,72 +527,3 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
 	// See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
 	return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
 }
-
-// FilterMatching gets a list of configs and only returns the ones with
-// matching root markers.
-func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
-	result := map[string]*powernapconfig.ServerConfig{}
-	if len(servers) == 0 {
-		return result
-	}
-
-	type serverPatterns struct {
-		server   *powernapconfig.ServerConfig
-		patterns []string
-	}
-	normalized := make(map[string]serverPatterns, len(servers))
-	for name, server := range servers {
-		var patterns []string
-		for _, p := range server.RootMarkers {
-			if p == ".git" {
-				// ignore .git for discovery
-				continue
-			}
-			patterns = append(patterns, filepath.ToSlash(p))
-		}
-		if len(patterns) == 0 {
-			slog.Debug("ignoring lsp with no root markers", "name", name)
-			continue
-		}
-		normalized[name] = serverPatterns{server: server, patterns: patterns}
-	}
-
-	walker := fsext.NewFastGlobWalker(dir)
-	_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
-		if err != nil {
-			return nil
-		}
-
-		if walker.ShouldSkip(path) {
-			if d.IsDir() {
-				return filepath.SkipDir
-			}
-			return nil
-		}
-
-		relPath, err := filepath.Rel(dir, path)
-		if err != nil {
-			return nil
-		}
-		relPath = filepath.ToSlash(relPath)
-
-		for name, sp := range normalized {
-			for _, pattern := range sp.patterns {
-				matched, err := doublestar.Match(pattern, relPath)
-				if err != nil || !matched {
-					continue
-				}
-				result[name] = sp.server
-				delete(normalized, name)
-				break
-			}
-		}
-
-		if len(normalized) == 0 {
-			return filepath.SkipAll
-		}
-		return nil
-	})
-
-	return result
-}

internal/lsp/client_test.go 🔗

@@ -23,7 +23,7 @@ func TestClient(t *testing.T) {
 	// but we can still test the basic structure
 	client, err := New(ctx, "test", cfg, config.NewEnvironmentVariableResolver(env.NewFromMap(map[string]string{
 		"THE_CMD": "echo",
-	})))
+	})), ".", false)
 	if err != nil {
 		// Expected to fail with echo command, skip the rest
 		t.Skipf("Powernap client creation failed as expected with dummy command: %v", err)

internal/lsp/filtermatching_test.go 🔗

@@ -1,111 +0,0 @@
-package lsp
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-
-	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
-	"github.com/stretchr/testify/require"
-)
-
-func TestFilterMatching(t *testing.T) {
-	t.Parallel()
-
-	t.Run("matches servers with existing root markers", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":          {RootMarkers: []string{"go.mod", "go.work"}},
-			"rust-analyzer":  {RootMarkers: []string{"Cargo.toml"}},
-			"typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Contains(t, result, "gopls")
-		require.Contains(t, result, "rust-analyzer")
-		require.NotContains(t, result, "typescript-lsp")
-	})
-
-	t.Run("returns empty for empty servers", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{})
-
-		require.Empty(t, result)
-	})
-
-	t.Run("returns empty when no markers match", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":  {RootMarkers: []string{"go.mod"}},
-			"python": {RootMarkers: []string{"pyproject.toml"}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Empty(t, result)
-	})
-
-	t.Run("glob patterns work", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755))
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644))
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":  {RootMarkers: []string{"**/*.go"}},
-			"python": {RootMarkers: []string{"**/*.py"}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Contains(t, result, "gopls")
-		require.NotContains(t, result, "python")
-	})
-
-	t.Run("servers with empty root markers are not included", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":   {RootMarkers: []string{"go.mod"}},
-			"generic": {RootMarkers: []string{}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Contains(t, result, "gopls")
-		require.NotContains(t, result, "generic")
-	})
-
-	t.Run("stops early when all servers match", func(t *testing.T) {
-		t.Parallel()
-		tmpDir := t.TempDir()
-
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
-		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
-
-		servers := map[string]*powernapconfig.ServerConfig{
-			"gopls":         {RootMarkers: []string{"go.mod"}},
-			"rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
-		}
-
-		result := FilterMatching(tmpDir, servers)
-
-		require.Len(t, result, 2)
-		require.Contains(t, result, "gopls")
-		require.Contains(t, result, "rust-analyzer")
-	})
-}

internal/lsp/handlers.go 🔗

@@ -5,7 +5,6 @@ import (
 	"encoding/json"
 	"log/slog"
 
-	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/lsp/util"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
@@ -80,11 +79,6 @@ func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatche
 
 // HandleServerMessage handles server messages
 func HandleServerMessage(_ context.Context, method string, params json.RawMessage) {
-	cfg := config.Get()
-	if !cfg.Options.DebugLSP {
-		return
-	}
-
 	var msg protocol.ShowMessageParams
 	if err := json.Unmarshal(params, &msg); err != nil {
 		slog.Debug("Server message", "type", msg.Type, "message", msg.Message)

internal/lsp/manager.go 🔗

@@ -0,0 +1,312 @@
+// Package lsp provides a manager for Language Server Protocol (LSP) clients.
+package lsp
+
+import (
+	"cmp"
+	"context"
+	"errors"
+	"io"
+	"log/slog"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/fsext"
+	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
+	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
+	"github.com/sourcegraph/jsonrpc2"
+)
+
+// Manager handles lazy initialization of LSP clients based on file types.
+type Manager struct {
+	clients  *csync.Map[string, *Client]
+	cfg      *config.Config
+	manager  *powernapconfig.Manager
+	callback func(name string, client *Client)
+	mu       sync.Mutex
+}
+
+// NewManager creates a new LSP manager service.
+func NewManager(cfg *config.Config) *Manager {
+	manager := powernapconfig.NewManager()
+	manager.LoadDefaults()
+
+	// Merge user-configured LSPs into the manager.
+	for name, clientConfig := range cfg.LSP {
+		if clientConfig.Disabled {
+			slog.Debug("LSP disabled by user config", "name", name)
+			manager.RemoveServer(name)
+			continue
+		}
+
+		// HACK: the user might have the command name in their config instead
+		// of the actual name. Find and use the correct name.
+		actualName := resolveServerName(manager, name)
+		manager.AddServer(actualName, &powernapconfig.ServerConfig{
+			Command:     clientConfig.Command,
+			Args:        clientConfig.Args,
+			Environment: clientConfig.Env,
+			FileTypes:   clientConfig.FileTypes,
+			RootMarkers: clientConfig.RootMarkers,
+			InitOptions: clientConfig.InitOptions,
+			Settings:    clientConfig.Options,
+		})
+	}
+
+	return &Manager{
+		clients: csync.NewMap[string, *Client](),
+		cfg:     cfg,
+		manager: manager,
+	}
+}
+
+// Clients returns the map of LSP clients.
+func (s *Manager) Clients() *csync.Map[string, *Client] {
+	return s.clients
+}
+
+// SetCallback sets a callback that is invoked when a new LSP
+// client is successfully started. This allows the coordinator to add LSP tools.
+func (s *Manager) SetCallback(cb func(name string, client *Client)) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.callback = cb
+}
+
+// Start starts an LSP server that can handle the given file path.
+// If an appropriate LSP is already running, this is a no-op.
+func (s *Manager) Start(ctx context.Context, path string) {
+	if !fsext.HasPrefix(path, s.cfg.WorkingDir()) {
+		return
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	var wg sync.WaitGroup
+	for name, server := range s.manager.GetServers() {
+		if !handles(server, path, s.cfg.WorkingDir()) {
+			continue
+		}
+		wg.Go(func() {
+			s.startServer(ctx, name, server)
+		})
+	}
+	wg.Wait()
+}
+
+// skipAutoStartCommands contains commands that are too generic or ambiguous to
+// auto-start without explicit user configuration.
+var skipAutoStartCommands = map[string]bool{
+	"buck2":   true,
+	"buf":     true,
+	"cue":     true,
+	"dart":    true,
+	"deno":    true,
+	"dotnet":  true,
+	"dprint":  true,
+	"gleam":   true,
+	"java":    true,
+	"julia":   true,
+	"koka":    true,
+	"node":    true,
+	"npx":     true,
+	"perl":    true,
+	"plz":     true,
+	"python":  true,
+	"python3": true,
+	"R":       true,
+	"racket":  true,
+	"rome":    true,
+	"rubocop": true,
+	"ruff":    true,
+	"scarb":   true,
+	"solc":    true,
+	"stylua":  true,
+	"swipl":   true,
+	"tflint":  true,
+}
+
+func (s *Manager) startServer(ctx context.Context, name string, server *powernapconfig.ServerConfig) {
+	userConfigured := s.isUserConfigured(name)
+
+	if !userConfigured {
+		if _, err := exec.LookPath(server.Command); err != nil {
+			slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
+			return
+		}
+		if skipAutoStartCommands[server.Command] {
+			slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
+			return
+		}
+	}
+
+	cfg := s.buildConfig(name, server)
+	if client, ok := s.clients.Get(name); ok {
+		switch client.GetServerState() {
+		case StateReady, StateStarting:
+			s.callback(name, client)
+			// already done, return
+			return
+		}
+	}
+	client, err := New(
+		ctx,
+		name,
+		cfg,
+		s.cfg.Resolver(),
+		s.cfg.WorkingDir(),
+		s.cfg.Options.DebugLSP,
+	)
+	if err != nil {
+		slog.Error("Failed to create LSP client", "name", name, "error", err)
+		return
+	}
+	s.callback(name, client)
+
+	defer func() {
+		s.clients.Set(name, client)
+		s.callback(name, client)
+	}()
+
+	initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second)
+	defer cancel()
+
+	if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil {
+		slog.Error("LSP client initialization failed", "name", name, "error", err)
+		client.Close(ctx)
+		return
+	}
+
+	if err := client.WaitForServerReady(initCtx); err != nil {
+		slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err)
+		client.SetServerState(StateError)
+	} else {
+		client.SetServerState(StateReady)
+	}
+
+	slog.Debug("LSP client started", "name", name)
+}
+
+func (s *Manager) isUserConfigured(name string) bool {
+	cfg, ok := s.cfg.LSP[name]
+	return ok && !cfg.Disabled
+}
+
+func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
+	cfg := config.LSPConfig{
+		Command:     server.Command,
+		Args:        server.Args,
+		Env:         server.Environment,
+		FileTypes:   server.FileTypes,
+		RootMarkers: server.RootMarkers,
+		InitOptions: server.InitOptions,
+		Options:     server.Settings,
+	}
+	if userCfg, ok := s.cfg.LSP[name]; ok {
+		cfg.Timeout = userCfg.Timeout
+	}
+	return cfg
+}
+
+func resolveServerName(manager *powernapconfig.Manager, name string) string {
+	if _, ok := manager.GetServer(name); ok {
+		return name
+	}
+	for sname, server := range manager.GetServers() {
+		if server.Command == name {
+			return sname
+		}
+	}
+	return name
+}
+
+func handlesFiletype(sname string, fileTypes []string, filePath string) bool {
+	if len(fileTypes) == 0 {
+		return true
+	}
+
+	kind := powernap.DetectLanguage(filePath)
+	name := strings.ToLower(filepath.Base(filePath))
+	for _, filetype := range fileTypes {
+		suffix := strings.ToLower(filetype)
+		if !strings.HasPrefix(suffix, ".") {
+			suffix = "." + suffix
+		}
+		if strings.HasSuffix(name, suffix) || filetype == string(kind) {
+			slog.Debug("Handles file", "name", sname, "file", name, "filetype", filetype, "kind", kind)
+			return true
+		}
+	}
+
+	slog.Debug("Doesn't handle file", "name", sname, "file", name)
+	return false
+}
+
+func hasRootMarkers(dir string, markers []string) bool {
+	if len(markers) == 0 {
+		return true
+	}
+	for _, pattern := range markers {
+		// Use fsext.GlobWithDoubleStar to find matches
+		matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
+		if err == nil && len(matches) > 0 {
+			return true
+		}
+	}
+	return false
+}
+
+func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool {
+	return handlesFiletype(server.Command, server.FileTypes, filePath) &&
+		hasRootMarkers(workDir, server.RootMarkers)
+}
+
+// KillAll force-kills all the LSP clients.
+//
+// This is generally faster than [Manager.StopAll] because it doesn't wait for
+// the server to exit gracefully, but it can lead to data loss if the server is
+// in the middle of writing something.
+// Generally it doesn't matter when shutting down Crush, though.
+func (s *Manager) KillAll(context.Context) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	var wg sync.WaitGroup
+	for name, client := range s.clients.Seq2() {
+		wg.Go(func() {
+			defer func() { s.callback(name, client) }()
+			client.client.Kill()
+			client.SetServerState(StateStopped)
+			slog.Debug("Killed LSP client", "name", name)
+		})
+	}
+	wg.Wait()
+}
+
+// StopAll stops all running LSP clients and clears the client map.
+func (s *Manager) StopAll(ctx context.Context) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	var wg sync.WaitGroup
+	for name, client := range s.clients.Seq2() {
+		wg.Go(func() {
+			defer func() { s.callback(name, client) }()
+			if err := client.Close(ctx); err != nil &&
+				!errors.Is(err, io.EOF) &&
+				!errors.Is(err, context.Canceled) &&
+				!errors.Is(err, jsonrpc2.ErrClosed) &&
+				err.Error() != "signal: killed" {
+				slog.Warn("Failed to stop LSP client", "name", name, "error", err)
+			}
+			client.SetServerState(StateStopped)
+			slog.Debug("Stopped LSP client", "name", name)
+		})
+	}
+	wg.Wait()
+}

internal/projects/projects_test.go 🔗

@@ -12,6 +12,7 @@ func TestRegisterAndList(t *testing.T) {
 
 	// Override the projects file path for testing
 	t.Setenv("XDG_DATA_HOME", tmpDir)
+	t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
 
 	// Test registering a project
 	err := Register("/home/user/project1", "/home/user/project1/.crush")
@@ -61,6 +62,7 @@ func TestRegisterAndList(t *testing.T) {
 func TestRegisterUpdatesExisting(t *testing.T) {
 	tmpDir := t.TempDir()
 	t.Setenv("XDG_DATA_HOME", tmpDir)
+	t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
 
 	// Register a project
 	err := Register("/home/user/project1", "/home/user/project1/.crush")
@@ -97,6 +99,7 @@ func TestRegisterUpdatesExisting(t *testing.T) {
 func TestLoadEmptyFile(t *testing.T) {
 	tmpDir := t.TempDir()
 	t.Setenv("XDG_DATA_HOME", tmpDir)
+	t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
 
 	// List before any projects exist
 	projects, err := List()
@@ -112,6 +115,7 @@ func TestLoadEmptyFile(t *testing.T) {
 func TestProjectsFilePath(t *testing.T) {
 	tmpDir := t.TempDir()
 	t.Setenv("XDG_DATA_HOME", tmpDir)
+	t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
 
 	expected := filepath.Join(tmpDir, "crush", "projects.json")
 	actual := projectsFilePath()
@@ -124,6 +128,7 @@ func TestProjectsFilePath(t *testing.T) {
 func TestRegisterWithParentDataDir(t *testing.T) {
 	tmpDir := t.TempDir()
 	t.Setenv("XDG_DATA_HOME", tmpDir)
+	t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
 
 	// Register a project where .crush is in a parent directory.
 	// e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush
@@ -153,6 +158,7 @@ func TestRegisterWithParentDataDir(t *testing.T) {
 func TestRegisterWithExternalDataDir(t *testing.T) {
 	tmpDir := t.TempDir()
 	t.Setenv("XDG_DATA_HOME", tmpDir)
+	t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
 
 	// Register a project where .crush is in a completely different location.
 	// e.g., project at /home/user/project but data stored at /var/data/crush/myproject

internal/shell/background.go 🔗

@@ -191,29 +191,23 @@ func (m *BackgroundShellManager) Cleanup() int {
 	return len(toRemove)
 }
 
-// KillAll terminates all background shells.
-func (m *BackgroundShellManager) KillAll() {
+// KillAll terminates all background shells. The provided context bounds how
+// long the function waits for each shell to exit.
+func (m *BackgroundShellManager) KillAll(ctx context.Context) {
 	shells := slices.Collect(m.shells.Seq())
 	m.shells.Reset(map[string]*BackgroundShell{})
-	done := make(chan struct{}, 1)
-	go func() {
-		var wg sync.WaitGroup
-		for _, shell := range shells {
-			wg.Go(func() {
-				shell.cancel()
-				<-shell.done
-			})
-		}
-		wg.Wait()
-		done <- struct{}{}
-	}()
 
-	select {
-	case <-done:
-		return
-	case <-time.After(time.Second * 5):
-		return
+	var wg sync.WaitGroup
+	for _, shell := range shells {
+		wg.Go(func() {
+			shell.cancel()
+			select {
+			case <-shell.done:
+			case <-ctx.Done():
+			}
+		})
 	}
+	wg.Wait()
 }
 
 // GetOutput returns the current output of a background shell.

internal/shell/background_test.go 🔗

@@ -6,13 +6,15 @@ import (
 	"strings"
 	"testing"
 	"time"
+
+	"github.com/stretchr/testify/require"
 )
 
 func TestBackgroundShellManager_Start(t *testing.T) {
 	t.Skip("Skipping this until I figure out why its flaky")
 	t.Parallel()
 
-	ctx := context.Background()
+	ctx := t.Context()
 	workingDir := t.TempDir()
 	manager := newBackgroundShellManager()
 
@@ -49,7 +51,7 @@ func TestBackgroundShellManager_Start(t *testing.T) {
 func TestBackgroundShellManager_Get(t *testing.T) {
 	t.Parallel()
 
-	ctx := context.Background()
+	ctx := t.Context()
 	workingDir := t.TempDir()
 	manager := newBackgroundShellManager()
 
@@ -75,7 +77,7 @@ func TestBackgroundShellManager_Get(t *testing.T) {
 func TestBackgroundShellManager_Kill(t *testing.T) {
 	t.Parallel()
 
-	ctx := context.Background()
+	ctx := t.Context()
 	workingDir := t.TempDir()
 	manager := newBackgroundShellManager()
 
@@ -117,7 +119,7 @@ func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
 func TestBackgroundShell_IsDone(t *testing.T) {
 	t.Parallel()
 
-	ctx := context.Background()
+	ctx := t.Context()
 	workingDir := t.TempDir()
 	manager := newBackgroundShellManager()
 
@@ -140,7 +142,7 @@ func TestBackgroundShell_IsDone(t *testing.T) {
 func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
 	t.Parallel()
 
-	ctx := context.Background()
+	ctx := t.Context()
 	workingDir := t.TempDir()
 	manager := newBackgroundShellManager()
 
@@ -178,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
 
 	t.Parallel()
 
-	ctx := context.Background()
+	ctx := t.Context()
 	workingDir := t.TempDir()
 	manager := newBackgroundShellManager()
 
@@ -222,7 +224,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
 func TestBackgroundShellManager_KillAll(t *testing.T) {
 	t.Parallel()
 
-	ctx := context.Background()
+	ctx := t.Context()
 	workingDir := t.TempDir()
 	manager := newBackgroundShellManager()
 
@@ -248,7 +250,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
 	}
 
 	// Kill all shells
-	manager.KillAll()
+	manager.KillAll(t.Context())
 
 	// Verify all shells are done
 	if !shell1.IsDone() {
@@ -280,3 +282,28 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
 		}
 	}
 }
+
+func TestBackgroundShellManager_KillAll_Timeout(t *testing.T) {
+	t.Parallel()
+
+	// XXX: can't use synctest here - causes --race to trip.
+
+	workingDir := t.TempDir()
+	manager := newBackgroundShellManager()
+
+	// Start a shell that traps signals and ignores cancellation.
+	_, err := manager.Start(t.Context(), workingDir, nil, "trap '' TERM INT; sleep 60", "")
+	require.NoError(t, err)
+
+	// Short timeout to test the timeout path.
+	ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
+	t.Cleanup(cancel)
+
+	start := time.Now()
+	manager.KillAll(ctx)
+
+	elapsed := time.Since(start)
+
+	// Must return promptly after timeout, not hang for 60 seconds.
+	require.Less(t, elapsed, 2*time.Second)
+}

internal/shell/shell.go 🔗

@@ -207,8 +207,8 @@ func splitArgsFlags(parts []string) (args []string, flags []string) {
 		if strings.HasPrefix(part, "-") {
 			// Extract flag name before '=' if present
 			flag := part
-			if idx := strings.IndexByte(part, '='); idx != -1 {
-				flag = part[:idx]
+			if before, _, ok := strings.Cut(part, "="); ok {
+				flag = before
 			}
 			flags = append(flags, flag)
 		} else {

internal/tui/components/anim/anim.go 🔗

@@ -1,447 +0,0 @@
-// Package anim provides an animated spinner.
-package anim
-
-import (
-	"fmt"
-	"image/color"
-	"math/rand/v2"
-	"strings"
-	"sync/atomic"
-	"time"
-
-	"github.com/zeebo/xxh3"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/lucasb-eyer/go-colorful"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
-	fps           = 20
-	initialChar   = '.'
-	labelGap      = " "
-	labelGapWidth = 1
-
-	// Periods of ellipsis animation speed in steps.
-	//
-	// If the FPS is 20 (50 milliseconds) this means that the ellipsis will
-	// change every 8 frames (400 milliseconds).
-	ellipsisAnimSpeed = 8
-
-	// The maximum amount of time that can pass before a character appears.
-	// This is used to create a staggered entrance effect.
-	maxBirthOffset = time.Second
-
-	// Number of frames to prerender for the animation. After this number
-	// of frames, the animation will loop. This only applies when color
-	// cycling is disabled.
-	prerenderedFrames = 10
-
-	// Default number of cycling chars.
-	defaultNumCyclingChars = 10
-)
-
-// Default colors for gradient.
-var (
-	defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
-	defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
-	defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
-)
-
-var (
-	availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
-	ellipsisFrames = []string{".", "..", "...", ""}
-)
-
-// Internal ID management. Used during animating to ensure that frame messages
-// are received only by spinner components that sent them.
-var lastID int64
-
-func nextID() int {
-	return int(atomic.AddInt64(&lastID, 1))
-}
-
-// Cache for expensive animation calculations
-type animCache struct {
-	initialFrames  [][]string
-	cyclingFrames  [][]string
-	width          int
-	labelWidth     int
-	label          []string
-	ellipsisFrames []string
-}
-
-var animCacheMap = csync.NewMap[string, *animCache]()
-
-// settingsHash creates a hash key for the settings to use for caching
-func settingsHash(opts Settings) string {
-	h := xxh3.New()
-	fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
-		opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
-	return fmt.Sprintf("%x", h.Sum(nil))
-}
-
-// StepMsg is a message type used to trigger the next step in the animation.
-type StepMsg struct{ id int }
-
-// Settings defines settings for the animation.
-type Settings struct {
-	Size        int
-	Label       string
-	LabelColor  color.Color
-	GradColorA  color.Color
-	GradColorB  color.Color
-	CycleColors bool
-}
-
-// Default settings.
-const ()
-
-// Anim is a Bubble for an animated spinner.
-type Anim struct {
-	width            int
-	cyclingCharWidth int
-	label            *csync.Slice[string]
-	labelWidth       int
-	labelColor       color.Color
-	startTime        time.Time
-	birthOffsets     []time.Duration
-	initialFrames    [][]string // frames for the initial characters
-	initialized      atomic.Bool
-	cyclingFrames    [][]string           // frames for the cycling characters
-	step             atomic.Int64         // current main frame step
-	ellipsisStep     atomic.Int64         // current ellipsis frame step
-	ellipsisFrames   *csync.Slice[string] // ellipsis animation frames
-	id               int
-}
-
-// New creates a new Anim instance with the specified width and label.
-func New(opts Settings) *Anim {
-	a := &Anim{}
-	// Validate settings.
-	if opts.Size < 1 {
-		opts.Size = defaultNumCyclingChars
-	}
-	if colorIsUnset(opts.GradColorA) {
-		opts.GradColorA = defaultGradColorA
-	}
-	if colorIsUnset(opts.GradColorB) {
-		opts.GradColorB = defaultGradColorB
-	}
-	if colorIsUnset(opts.LabelColor) {
-		opts.LabelColor = defaultLabelColor
-	}
-
-	a.id = nextID()
-	a.startTime = time.Now()
-	a.cyclingCharWidth = opts.Size
-	a.labelColor = opts.LabelColor
-
-	// Check cache first
-	cacheKey := settingsHash(opts)
-	cached, exists := animCacheMap.Get(cacheKey)
-
-	if exists {
-		// Use cached values
-		a.width = cached.width
-		a.labelWidth = cached.labelWidth
-		a.label = csync.NewSliceFrom(cached.label)
-		a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
-		a.initialFrames = cached.initialFrames
-		a.cyclingFrames = cached.cyclingFrames
-	} else {
-		// Generate new values and cache them
-		a.labelWidth = lipgloss.Width(opts.Label)
-
-		// Total width of anim, in cells.
-		a.width = opts.Size
-		if opts.Label != "" {
-			a.width += labelGapWidth + lipgloss.Width(opts.Label)
-		}
-
-		// Render the label
-		a.renderLabel(opts.Label)
-
-		// Pre-generate gradient.
-		var ramp []color.Color
-		numFrames := prerenderedFrames
-		if opts.CycleColors {
-			ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
-			numFrames = a.width * 2
-		} else {
-			ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
-		}
-
-		// Pre-render initial characters.
-		a.initialFrames = make([][]string, numFrames)
-		offset := 0
-		for i := range a.initialFrames {
-			a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
-			for j := range a.initialFrames[i] {
-				if j+offset >= len(ramp) {
-					continue // skip if we run out of colors
-				}
-
-				var c color.Color
-				if j <= a.cyclingCharWidth {
-					c = ramp[j+offset]
-				} else {
-					c = opts.LabelColor
-				}
-
-				// Also prerender the initial character with Lip Gloss to avoid
-				// processing in the render loop.
-				a.initialFrames[i][j] = lipgloss.NewStyle().
-					Foreground(c).
-					Render(string(initialChar))
-			}
-			if opts.CycleColors {
-				offset++
-			}
-		}
-
-		// Prerender scrambled rune frames for the animation.
-		a.cyclingFrames = make([][]string, numFrames)
-		offset = 0
-		for i := range a.cyclingFrames {
-			a.cyclingFrames[i] = make([]string, a.width)
-			for j := range a.cyclingFrames[i] {
-				if j+offset >= len(ramp) {
-					continue // skip if we run out of colors
-				}
-
-				// Also prerender the color with Lip Gloss here to avoid processing
-				// in the render loop.
-				r := availableRunes[rand.IntN(len(availableRunes))]
-				a.cyclingFrames[i][j] = lipgloss.NewStyle().
-					Foreground(ramp[j+offset]).
-					Render(string(r))
-			}
-			if opts.CycleColors {
-				offset++
-			}
-		}
-
-		// Cache the results
-		labelSlice := make([]string, a.label.Len())
-		for i, v := range a.label.Seq2() {
-			labelSlice[i] = v
-		}
-		ellipsisSlice := make([]string, a.ellipsisFrames.Len())
-		for i, v := range a.ellipsisFrames.Seq2() {
-			ellipsisSlice[i] = v
-		}
-		cached = &animCache{
-			initialFrames:  a.initialFrames,
-			cyclingFrames:  a.cyclingFrames,
-			width:          a.width,
-			labelWidth:     a.labelWidth,
-			label:          labelSlice,
-			ellipsisFrames: ellipsisSlice,
-		}
-		animCacheMap.Set(cacheKey, cached)
-	}
-
-	// Random assign a birth to each character for a stagged entrance effect.
-	a.birthOffsets = make([]time.Duration, a.width)
-	for i := range a.birthOffsets {
-		a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
-	}
-
-	return a
-}
-
-// SetLabel updates the label text and re-renders it.
-func (a *Anim) SetLabel(newLabel string) {
-	a.labelWidth = lipgloss.Width(newLabel)
-
-	// Update total width
-	a.width = a.cyclingCharWidth
-	if newLabel != "" {
-		a.width += labelGapWidth + a.labelWidth
-	}
-
-	// Re-render the label
-	a.renderLabel(newLabel)
-}
-
-// renderLabel renders the label with the current label color.
-func (a *Anim) renderLabel(label string) {
-	if a.labelWidth > 0 {
-		// Pre-render the label.
-		labelRunes := []rune(label)
-		a.label = csync.NewSlice[string]()
-		for i := range labelRunes {
-			rendered := lipgloss.NewStyle().
-				Foreground(a.labelColor).
-				Render(string(labelRunes[i]))
-			a.label.Append(rendered)
-		}
-
-		// Pre-render the ellipsis frames which come after the label.
-		a.ellipsisFrames = csync.NewSlice[string]()
-		for _, frame := range ellipsisFrames {
-			rendered := lipgloss.NewStyle().
-				Foreground(a.labelColor).
-				Render(frame)
-			a.ellipsisFrames.Append(rendered)
-		}
-	} else {
-		a.label = csync.NewSlice[string]()
-		a.ellipsisFrames = csync.NewSlice[string]()
-	}
-}
-
-// Width returns the total width of the animation.
-func (a *Anim) Width() (w int) {
-	w = a.width
-	if a.labelWidth > 0 {
-		w += labelGapWidth + a.labelWidth
-
-		var widestEllipsisFrame int
-		for _, f := range ellipsisFrames {
-			fw := lipgloss.Width(f)
-			if fw > widestEllipsisFrame {
-				widestEllipsisFrame = fw
-			}
-		}
-		w += widestEllipsisFrame
-	}
-	return w
-}
-
-// Init starts the animation.
-func (a *Anim) Init() tea.Cmd {
-	return a.Step()
-}
-
-// Update processes animation steps (or not).
-func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case StepMsg:
-		if msg.id != a.id {
-			// Reject messages that are not for this instance.
-			return a, nil
-		}
-
-		step := a.step.Add(1)
-		if int(step) >= len(a.cyclingFrames) {
-			a.step.Store(0)
-		}
-
-		if a.initialized.Load() && a.labelWidth > 0 {
-			// Manage the ellipsis animation.
-			ellipsisStep := a.ellipsisStep.Add(1)
-			if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
-				a.ellipsisStep.Store(0)
-			}
-		} else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
-			a.initialized.Store(true)
-		}
-		return a, a.Step()
-	default:
-		return a, nil
-	}
-}
-
-// View renders the current state of the animation.
-func (a *Anim) View() string {
-	var b strings.Builder
-	step := int(a.step.Load())
-	for i := range a.width {
-		switch {
-		case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
-			// Birth offset not reached: render initial character.
-			b.WriteString(a.initialFrames[step][i])
-		case i < a.cyclingCharWidth:
-			// Render a cycling character.
-			b.WriteString(a.cyclingFrames[step][i])
-		case i == a.cyclingCharWidth:
-			// Render label gap.
-			b.WriteString(labelGap)
-		case i > a.cyclingCharWidth:
-			// Label.
-			if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
-				b.WriteString(labelChar)
-			}
-		}
-	}
-	// Render animated ellipsis at the end of the label if all characters
-	// have been initialized.
-	if a.initialized.Load() && a.labelWidth > 0 {
-		ellipsisStep := int(a.ellipsisStep.Load())
-		if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
-			b.WriteString(ellipsisFrame)
-		}
-	}
-
-	return b.String()
-}
-
-// Step is a command that triggers the next step in the animation.
-func (a *Anim) Step() tea.Cmd {
-	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
-		return StepMsg{id: a.id}
-	})
-}
-
-// makeGradientRamp() returns a slice of colors blended between the given keys.
-// Blending is done as Hcl to stay in gamut.
-func makeGradientRamp(size int, stops ...color.Color) []color.Color {
-	if len(stops) < 2 {
-		return nil
-	}
-
-	points := make([]colorful.Color, len(stops))
-	for i, k := range stops {
-		points[i], _ = colorful.MakeColor(k)
-	}
-
-	numSegments := len(stops) - 1
-	if numSegments == 0 {
-		return nil
-	}
-	blended := make([]color.Color, 0, size)
-
-	// Calculate how many colors each segment should have.
-	segmentSizes := make([]int, numSegments)
-	baseSize := size / numSegments
-	remainder := size % numSegments
-
-	// Distribute the remainder across segments.
-	for i := range numSegments {
-		segmentSizes[i] = baseSize
-		if i < remainder {
-			segmentSizes[i]++
-		}
-	}
-
-	// Generate colors for each segment.
-	for i := range numSegments {
-		c1 := points[i]
-		c2 := points[i+1]
-		segmentSize := segmentSizes[i]
-
-		for j := range segmentSize {
-			if segmentSize == 0 {
-				continue
-			}
-			t := float64(j) / float64(segmentSize)
-			c := c1.BlendHcl(c2, t)
-			blended = append(blended, c)
-		}
-	}
-
-	return blended
-}
-
-func colorIsUnset(c color.Color) bool {
-	if c == nil {
-		return true
-	}
-	_, _, _, a := c.RGBA()
-	return a == 0
-}

internal/tui/components/chat/chat.go 🔗

@@ -1,782 +0,0 @@
-package chat
-
-import (
-	"context"
-	"time"
-
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"github.com/atotto/clipboard"
-	"github.com/charmbracelet/crush/internal/agent"
-	"github.com/charmbracelet/crush/internal/agent/tools"
-	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type SendMsg struct {
-	Text        string
-	Attachments []message.Attachment
-}
-
-type SessionSelectedMsg = session.Session
-
-type SessionClearedMsg struct{}
-
-type SelectionCopyMsg struct {
-	clickCount   int
-	endSelection bool
-	x, y         int
-}
-
-const (
-	NotFound = -1
-)
-
-// MessageListCmp represents a component that displays a list of chat messages
-// with support for real-time updates and session management.
-type MessageListCmp interface {
-	util.Model
-	layout.Sizeable
-	layout.Focusable
-	layout.Help
-
-	SetSession(session.Session) tea.Cmd
-	GoToBottom() tea.Cmd
-	GetSelectedText() string
-	CopySelectedText(bool) tea.Cmd
-}
-
-// messageListCmp implements MessageListCmp, providing a virtualized list
-// of chat messages with support for tool calls, real-time updates, and
-// session switching.
-type messageListCmp struct {
-	app              *app.App
-	width, height    int
-	session          session.Session
-	listCmp          list.List[list.Item]
-	previousSelected string // Last selected item index for restoring focus
-
-	lastUserMessageTime int64
-	defaultListKeyMap   list.KeyMap
-
-	// Click tracking for double/triple click detection
-	lastClickTime time.Time
-	lastClickX    int
-	lastClickY    int
-	clickCount    int
-}
-
-// New creates a new message list component with custom keybindings
-// and reverse ordering (newest messages at bottom).
-func New(app *app.App) MessageListCmp {
-	defaultListKeyMap := list.DefaultKeyMap()
-	listCmp := list.New(
-		[]list.Item{},
-		list.WithGap(1),
-		list.WithDirectionBackward(),
-		list.WithFocus(false),
-		list.WithKeyMap(defaultListKeyMap),
-		list.WithEnableMouse(),
-	)
-	return &messageListCmp{
-		app:               app,
-		listCmp:           listCmp,
-		previousSelected:  "",
-		defaultListKeyMap: defaultListKeyMap,
-	}
-}
-
-// Init initializes the component.
-func (m *messageListCmp) Init() tea.Cmd {
-	return m.listCmp.Init()
-}
-
-// Update handles incoming messages and updates the component state.
-func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case tea.KeyPressMsg:
-		if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
-			switch {
-			case key.Matches(msg, messages.CopyKey):
-				cmds = append(cmds, m.CopySelectedText(true))
-				return m, tea.Batch(cmds...)
-			case key.Matches(msg, messages.ClearSelectionKey):
-				cmds = append(cmds, m.SelectionClear())
-				return m, tea.Batch(cmds...)
-			}
-		}
-	case tea.MouseClickMsg:
-		x := msg.X - 1 // Adjust for padding
-		y := msg.Y - 1 // Adjust for padding
-		if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
-			return m, nil // Ignore clicks outside the component
-		}
-		if msg.Button == tea.MouseLeft {
-			cmds = append(cmds, m.handleMouseClick(x, y))
-			return m, tea.Batch(cmds...)
-		}
-		return m, tea.Batch(cmds...)
-	case tea.MouseMotionMsg:
-		x := msg.X - 1 // Adjust for padding
-		y := msg.Y - 1 // Adjust for padding
-		if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
-			if y < 0 {
-				cmds = append(cmds, m.listCmp.MoveUp(1))
-				return m, tea.Batch(cmds...)
-			}
-			if y >= m.height-1 {
-				cmds = append(cmds, m.listCmp.MoveDown(1))
-				return m, tea.Batch(cmds...)
-			}
-			return m, nil // Ignore clicks outside the component
-		}
-		if msg.Button == tea.MouseLeft {
-			m.listCmp.EndSelection(x, y)
-		}
-		return m, tea.Batch(cmds...)
-	case tea.MouseReleaseMsg:
-		x := msg.X - 1 // Adjust for padding
-		y := msg.Y - 1 // Adjust for padding
-		if msg.Button == tea.MouseLeft {
-			clickCount := m.clickCount
-			if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
-				tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
-					return SelectionCopyMsg{
-						clickCount:   clickCount,
-						endSelection: false,
-					}
-				})
-
-				cmds = append(cmds, tick)
-				return m, tea.Batch(cmds...)
-			}
-			tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
-				return SelectionCopyMsg{
-					clickCount:   clickCount,
-					endSelection: true,
-					x:            x,
-					y:            y,
-				}
-			})
-			cmds = append(cmds, tick)
-			return m, tea.Batch(cmds...)
-		}
-		return m, nil
-	case SelectionCopyMsg:
-		if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
-			// If the click count matches and within threshold, copy selected text
-			if msg.endSelection {
-				m.listCmp.EndSelection(msg.x, msg.y)
-			}
-			m.listCmp.SelectionStop()
-			cmds = append(cmds, m.CopySelectedText(true))
-			return m, tea.Batch(cmds...)
-		}
-	case pubsub.Event[permission.PermissionNotification]:
-		cmds = append(cmds, m.handlePermissionRequest(msg.Payload))
-		return m, tea.Batch(cmds...)
-	case SessionSelectedMsg:
-		if msg.ID != m.session.ID {
-			cmds = append(cmds, m.SetSession(msg))
-		}
-		return m, tea.Batch(cmds...)
-	case SessionClearedMsg:
-		m.session = session.Session{}
-		cmds = append(cmds, m.listCmp.SetItems([]list.Item{}))
-		return m, tea.Batch(cmds...)
-
-	case pubsub.Event[message.Message]:
-		cmds = append(cmds, m.handleMessageEvent(msg))
-		return m, tea.Batch(cmds...)
-
-	case tea.MouseWheelMsg:
-		u, cmd := m.listCmp.Update(msg)
-		m.listCmp = u.(list.List[list.Item])
-		cmds = append(cmds, cmd)
-		return m, tea.Batch(cmds...)
-	}
-
-	u, cmd := m.listCmp.Update(msg)
-	m.listCmp = u.(list.List[list.Item])
-	cmds = append(cmds, cmd)
-	return m, tea.Batch(cmds...)
-}
-
-// View renders the message list or an initial screen if empty.
-func (m *messageListCmp) View() string {
-	t := styles.CurrentTheme()
-	return t.S().Base.
-		Padding(1, 1, 0, 1).
-		Width(m.width).
-		Height(m.height).
-		Render(m.listCmp.View())
-}
-
-func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
-	items := m.listCmp.Items()
-	if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
-		toolCall := items[toolCallIndex].(messages.ToolCallCmp)
-		toolCall.SetPermissionRequested()
-		if permission.Granted {
-			toolCall.SetPermissionGranted()
-		}
-		m.listCmp.UpdateItem(toolCall.ID(), toolCall)
-	}
-	return nil
-}
-
-// handleChildSession handles messages from child sessions (agent tools).
-func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
-	var cmds []tea.Cmd
-	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
-		return nil
-	}
-
-	// Check if this is an agent tool session and parse it
-	childSessionID := event.Payload.SessionID
-	parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID)
-	if !ok {
-		return nil
-	}
-	items := m.listCmp.Items()
-	toolCallInx := NotFound
-	var toolCall messages.ToolCallCmp
-	for i := len(items) - 1; i >= 0; i-- {
-		if msg, ok := items[i].(messages.ToolCallCmp); ok {
-			if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID {
-				toolCallInx = i
-				toolCall = msg
-			}
-		}
-	}
-	if toolCallInx == NotFound {
-		return nil
-	}
-	nestedToolCalls := toolCall.GetNestedToolCalls()
-	for _, tc := range event.Payload.ToolCalls() {
-		found := false
-		for existingInx, existingTC := range nestedToolCalls {
-			if existingTC.GetToolCall().ID == tc.ID {
-				nestedToolCalls[existingInx].SetToolCall(tc)
-				found = true
-				break
-			}
-		}
-		if !found {
-			nestedCall := messages.NewToolCallCmp(
-				event.Payload.ID,
-				tc,
-				m.app.Permissions,
-				messages.WithToolCallNested(true),
-			)
-			cmds = append(cmds, nestedCall.Init())
-			nestedToolCalls = append(
-				nestedToolCalls,
-				nestedCall,
-			)
-		}
-	}
-	for _, tr := range event.Payload.ToolResults() {
-		for nestedInx, nestedTC := range nestedToolCalls {
-			if nestedTC.GetToolCall().ID == tr.ToolCallID {
-				nestedToolCalls[nestedInx].SetToolResult(tr)
-				break
-			}
-		}
-	}
-
-	toolCall.SetNestedToolCalls(nestedToolCalls)
-	m.listCmp.UpdateItem(
-		toolCall.ID(),
-		toolCall,
-	)
-	return tea.Batch(cmds...)
-}
-
-// handleMessageEvent processes different types of message events (created/updated).
-func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
-	switch event.Type {
-	case pubsub.CreatedEvent:
-		if event.Payload.SessionID != m.session.ID {
-			return m.handleChildSession(event)
-		}
-		if m.messageExists(event.Payload.ID) {
-			return nil
-		}
-		return m.handleNewMessage(event.Payload)
-	case pubsub.DeletedEvent:
-		if event.Payload.SessionID != m.session.ID {
-			return nil
-		}
-		return m.handleDeleteMessage(event.Payload)
-	case pubsub.UpdatedEvent:
-		if event.Payload.SessionID != m.session.ID {
-			return m.handleChildSession(event)
-		}
-		switch event.Payload.Role {
-		case message.Assistant:
-			return m.handleUpdateAssistantMessage(event.Payload)
-		case message.Tool:
-			return m.handleToolMessage(event.Payload)
-		}
-	}
-	return nil
-}
-
-// messageExists checks if a message with the given ID already exists in the list.
-func (m *messageListCmp) messageExists(messageID string) bool {
-	items := m.listCmp.Items()
-	// Search backwards as new messages are more likely to be at the end
-	for i := len(items) - 1; i >= 0; i-- {
-		if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
-			return true
-		}
-	}
-	return false
-}
-
-// handleDeleteMessage removes a message from the list.
-func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
-	items := m.listCmp.Items()
-	for i := len(items) - 1; i >= 0; i-- {
-		if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
-			m.listCmp.DeleteItem(items[i].ID())
-			return nil
-		}
-	}
-	return nil
-}
-
-// handleNewMessage routes new messages to appropriate handlers based on role.
-func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
-	switch msg.Role {
-	case message.User:
-		return m.handleNewUserMessage(msg)
-	case message.Assistant:
-		return m.handleNewAssistantMessage(msg)
-	case message.Tool:
-		return m.handleToolMessage(msg)
-	}
-	return nil
-}
-
-// handleNewUserMessage adds a new user message to the list and updates the timestamp.
-func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
-	m.lastUserMessageTime = msg.CreatedAt
-	return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
-}
-
-// handleToolMessage updates existing tool calls with their results.
-func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
-	items := m.listCmp.Items()
-	for _, tr := range msg.ToolResults() {
-		if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
-			toolCall := items[toolCallIndex].(messages.ToolCallCmp)
-			toolCall.SetToolResult(tr)
-			m.listCmp.UpdateItem(toolCall.ID(), toolCall)
-		}
-	}
-	return nil
-}
-
-// findToolCallByID searches for a tool call with the specified ID.
-// Returns the index if found, NotFound otherwise.
-func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
-	// Search backwards as tool calls are more likely to be recent
-	for i := len(items) - 1; i >= 0; i-- {
-		if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
-			return i
-		}
-	}
-	return NotFound
-}
-
-// handleUpdateAssistantMessage processes updates to assistant messages,
-// managing both message content and associated tool calls.
-func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
-	var cmds []tea.Cmd
-	items := m.listCmp.Items()
-
-	// Find existing assistant message and tool calls for this message
-	assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
-
-	// Handle assistant message content
-	if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-
-	// Handle tool calls
-	if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-
-	return tea.Batch(cmds...)
-}
-
-// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
-func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
-	assistantIndex := NotFound
-	toolCalls := make(map[int]messages.ToolCallCmp)
-
-	// Search backwards as messages are more likely to be at the end
-	for i := len(items) - 1; i >= 0; i-- {
-		item := items[i]
-		if asMsg, ok := item.(messages.MessageCmp); ok {
-			if asMsg.GetMessage().ID == messageID {
-				assistantIndex = i
-			}
-		} else if tc, ok := item.(messages.ToolCallCmp); ok {
-			if tc.ParentMessageID() == messageID {
-				toolCalls[i] = tc
-			}
-		}
-	}
-
-	return assistantIndex, toolCalls
-}
-
-// updateAssistantMessageContent updates or removes the assistant message based on content.
-func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
-	if assistantIndex == NotFound {
-		return nil
-	}
-
-	shouldShowMessage := m.shouldShowAssistantMessage(msg)
-	hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
-
-	var cmd tea.Cmd
-	if shouldShowMessage {
-		items := m.listCmp.Items()
-		uiMsg := items[assistantIndex].(messages.MessageCmp)
-		uiMsg.SetMessage(msg)
-		m.listCmp.UpdateItem(
-			items[assistantIndex].ID(),
-			uiMsg,
-		)
-		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
-			m.listCmp.AppendItem(
-				messages.NewAssistantSection(
-					msg,
-					time.Unix(m.lastUserMessageTime, 0),
-				),
-			)
-		}
-	} else if hasToolCallsOnly {
-		items := m.listCmp.Items()
-		m.listCmp.DeleteItem(items[assistantIndex].ID())
-	}
-
-	return cmd
-}
-
-// shouldShowAssistantMessage determines if an assistant message should be displayed.
-func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
-	return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
-}
-
-// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
-func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
-	var cmds []tea.Cmd
-
-	for _, tc := range msg.ToolCalls() {
-		if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	}
-
-	return tea.Batch(cmds...)
-}
-
-// updateOrAddToolCall updates an existing tool call or adds a new one.
-func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
-	// Try to find existing tool call
-	for _, existingTC := range existingToolCalls {
-		if tc.ID == existingTC.GetToolCall().ID {
-			existingTC.SetToolCall(tc)
-			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
-				existingTC.SetCancelled()
-			}
-			m.listCmp.UpdateItem(tc.ID, existingTC)
-			return nil
-		}
-	}
-
-	// Add new tool call if not found
-	return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
-}
-
-// handleNewAssistantMessage processes new assistant messages and their tool calls.
-func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
-	var cmds []tea.Cmd
-
-	// Add assistant message if it should be displayed
-	if m.shouldShowAssistantMessage(msg) {
-		cmd := m.listCmp.AppendItem(
-			messages.NewMessageCmp(
-				msg,
-			),
-		)
-		cmds = append(cmds, cmd)
-	}
-
-	// Add tool calls
-	for _, tc := range msg.ToolCalls() {
-		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
-		cmds = append(cmds, cmd)
-	}
-
-	return tea.Batch(cmds...)
-}
-
-// SetSession loads and displays messages for a new session.
-func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
-	if m.session.ID == session.ID {
-		return nil
-	}
-
-	m.session = session
-	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
-	if err != nil {
-		return util.ReportError(err)
-	}
-
-	if len(sessionMessages) == 0 {
-		return m.listCmp.SetItems([]list.Item{})
-	}
-
-	// Initialize with first message timestamp
-	m.lastUserMessageTime = sessionMessages[0].CreatedAt
-
-	// Build tool result map for efficient lookup
-	toolResultMap := m.buildToolResultMap(sessionMessages)
-
-	// Convert messages to UI components
-	uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
-
-	return m.listCmp.SetItems(uiMessages)
-}
-
-// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
-func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
-	toolResultMap := make(map[string]message.ToolResult)
-	for _, msg := range messages {
-		for _, tr := range msg.ToolResults() {
-			toolResultMap[tr.ToolCallID] = tr
-		}
-	}
-	return toolResultMap
-}
-
-// convertMessagesToUI converts database messages to UI components.
-func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
-	uiMessages := make([]list.Item, 0)
-
-	for _, msg := range sessionMessages {
-		switch msg.Role {
-		case message.User:
-			m.lastUserMessageTime = msg.CreatedAt
-			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
-		case message.Assistant:
-			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
-			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
-				uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
-			}
-		}
-	}
-
-	return uiMessages
-}
-
-// convertAssistantMessage converts an assistant message and its tool calls to UI components.
-func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
-	var uiMessages []list.Item
-
-	// Add assistant message if it should be displayed
-	if m.shouldShowAssistantMessage(msg) {
-		uiMessages = append(
-			uiMessages,
-			messages.NewMessageCmp(
-				msg,
-			),
-		)
-	}
-
-	// Add tool calls with their results and status
-	for _, tc := range msg.ToolCalls() {
-		options := m.buildToolCallOptions(tc, msg, toolResultMap)
-		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
-		// If this tool call is the agent tool or agentic fetch, fetch nested tool calls
-		if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
-			agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
-			nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
-			nestedToolResultMap := m.buildToolResultMap(nestedMessages)
-			nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
-			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
-			for _, nestedMsg := range nestedUIMessages {
-				if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
-					toolCall.SetIsNested(true)
-					nestedToolCalls = append(nestedToolCalls, toolCall)
-				}
-			}
-			uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
-		}
-	}
-
-	return uiMessages
-}
-
-// buildToolCallOptions creates options for tool call components based on results and status.
-func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
-	var options []messages.ToolCallOption
-
-	// Add tool result if available
-	if tr, ok := toolResultMap[tc.ID]; ok {
-		options = append(options, messages.WithToolCallResult(tr))
-	}
-
-	// Add cancelled status if applicable
-	if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
-		options = append(options, messages.WithToolCallCancelled())
-	}
-
-	return options
-}
-
-// GetSize returns the current width and height of the component.
-func (m *messageListCmp) GetSize() (int, int) {
-	return m.width, m.height
-}
-
-// SetSize updates the component dimensions and propagates to the list component.
-func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
-	m.width = width
-	m.height = height
-	return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding
-}
-
-// Blur implements MessageListCmp.
-func (m *messageListCmp) Blur() tea.Cmd {
-	return m.listCmp.Blur()
-}
-
-// Focus implements MessageListCmp.
-func (m *messageListCmp) Focus() tea.Cmd {
-	return m.listCmp.Focus()
-}
-
-// IsFocused implements MessageListCmp.
-func (m *messageListCmp) IsFocused() bool {
-	return m.listCmp.IsFocused()
-}
-
-func (m *messageListCmp) Bindings() []key.Binding {
-	return m.defaultListKeyMap.KeyBindings()
-}
-
-func (m *messageListCmp) GoToBottom() tea.Cmd {
-	return m.listCmp.GoToBottom()
-}
-
-const (
-	doubleClickThreshold = 500 * time.Millisecond
-	clickTolerance       = 2 // pixels
-)
-
-// handleMouseClick handles mouse click events and detects double/triple clicks.
-func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
-	now := time.Now()
-
-	// Check if this is a potential multi-click
-	if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
-		abs(x-m.lastClickX) <= clickTolerance &&
-		abs(y-m.lastClickY) <= clickTolerance {
-		m.clickCount++
-	} else {
-		m.clickCount = 1
-	}
-
-	m.lastClickTime = now
-	m.lastClickX = x
-	m.lastClickY = y
-
-	switch m.clickCount {
-	case 1:
-		// Single click - start selection
-		m.listCmp.StartSelection(x, y)
-	case 2:
-		// Double click - select word
-		m.listCmp.SelectWord(x, y)
-	case 3:
-		// Triple click - select paragraph
-		m.listCmp.SelectParagraph(x, y)
-		m.clickCount = 0 // Reset after triple click
-	}
-
-	return nil
-}
-
-// SelectionClear clears the current selection in the list component.
-func (m *messageListCmp) SelectionClear() tea.Cmd {
-	m.listCmp.SelectionClear()
-	m.previousSelected = ""
-	m.lastClickX, m.lastClickY = 0, 0
-	m.lastClickTime = time.Time{}
-	m.clickCount = 0
-	return nil
-}
-
-// HasSelection checks if there is a selection in the list component.
-func (m *messageListCmp) HasSelection() bool {
-	return m.listCmp.HasSelection()
-}
-
-// GetSelectedText returns the currently selected text from the list component.
-func (m *messageListCmp) GetSelectedText() string {
-	return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
-}
-
-// CopySelectedText copies the currently selected text to the clipboard. When
-// clear is true, it clears the selection after copying.
-func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
-	if !m.listCmp.HasSelection() {
-		return nil
-	}
-
-	selectedText := m.GetSelectedText()
-	if selectedText == "" {
-		return util.ReportInfo("No text selected")
-	}
-
-	cmds := []tea.Cmd{
-		// We use both OSC 52 and native clipboard for compatibility with different
-		// terminal emulators and environments.
-		tea.SetClipboard(selectedText),
-		func() tea.Msg {
-			_ = clipboard.WriteAll(selectedText)
-			return nil
-		},
-		util.ReportInfo("Selected text copied to clipboard"),
-	}
-	if clear {
-		cmds = append(cmds, m.SelectionClear())
-	}
-
-	return tea.Sequence(cmds...)
-}
-
-// abs returns the absolute value of an integer.
-func abs(x int) int {
-	if x < 0 {
-		return -x
-	}
-	return x
-}

internal/tui/components/chat/editor/editor.go 🔗

@@ -1,780 +0,0 @@
-package editor
-
-import (
-	"context"
-	"fmt"
-	"math/rand"
-	"net/http"
-	"os"
-	"path/filepath"
-	"regexp"
-	"slices"
-	"strconv"
-	"strings"
-	"unicode"
-
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/textarea"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/completions"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/charmbracelet/x/editor"
-)
-
-var (
-	errClipboardPlatformUnsupported = fmt.Errorf("clipboard operations are not supported on this platform")
-	errClipboardUnknownFormat       = fmt.Errorf("unknown clipboard format")
-)
-
-// If pasted text has more than 10 newlines, treat it as a file attachment.
-const pasteLinesThreshold = 10
-
-type Editor interface {
-	util.Model
-	layout.Sizeable
-	layout.Focusable
-	layout.Help
-	layout.Positional
-
-	SetSession(session session.Session) tea.Cmd
-	IsCompletionsOpen() bool
-	HasAttachments() bool
-	IsEmpty() bool
-	Cursor() *tea.Cursor
-}
-
-type FileCompletionItem struct {
-	Path string // The file path
-}
-
-type editorCmp struct {
-	width              int
-	height             int
-	x, y               int
-	app                *app.App
-	session            session.Session
-	sessionFileReads   []string
-	textarea           textarea.Model
-	attachments        []message.Attachment
-	deleteMode         bool
-	readyPlaceholder   string
-	workingPlaceholder string
-
-	keyMap EditorKeyMap
-
-	// File path completions
-	currentQuery          string
-	completionsStartIndex int
-	isCompletionsOpen     bool
-}
-
-var DeleteKeyMaps = DeleteAttachmentKeyMaps{
-	AttachmentDeleteMode: key.NewBinding(
-		key.WithKeys("ctrl+r"),
-		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc", "alt+esc"),
-		key.WithHelp("esc", "cancel delete mode"),
-	),
-	DeleteAllAttachments: key.NewBinding(
-		key.WithKeys("r"),
-		key.WithHelp("ctrl+r+r", "delete all attachments"),
-	),
-}
-
-const maxFileResults = 25
-
-type OpenEditorMsg struct {
-	Text string
-}
-
-func (m *editorCmp) openEditor(value string) tea.Cmd {
-	tmpfile, err := os.CreateTemp("", "msg_*.md")
-	if err != nil {
-		return util.ReportError(err)
-	}
-	defer tmpfile.Close() //nolint:errcheck
-	if _, err := tmpfile.WriteString(value); err != nil {
-		return util.ReportError(err)
-	}
-	cmd, err := editor.Command(
-		"crush",
-		tmpfile.Name(),
-		editor.AtPosition(
-			m.textarea.Line()+1,
-			m.textarea.Column()+1,
-		),
-	)
-	if err != nil {
-		return util.ReportError(err)
-	}
-	return tea.ExecProcess(cmd, func(err error) tea.Msg {
-		if err != nil {
-			return util.ReportError(err)
-		}
-		content, err := os.ReadFile(tmpfile.Name())
-		if err != nil {
-			return util.ReportError(err)
-		}
-		if len(content) == 0 {
-			return util.ReportWarn("Message is empty")
-		}
-		os.Remove(tmpfile.Name())
-		return OpenEditorMsg{
-			Text: strings.TrimSpace(string(content)),
-		}
-	})
-}
-
-func (m *editorCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (m *editorCmp) send() tea.Cmd {
-	value := m.textarea.Value()
-	value = strings.TrimSpace(value)
-
-	switch value {
-	case "exit", "quit":
-		m.textarea.Reset()
-		return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
-	}
-
-	attachments := m.attachments
-
-	if value == "" && !message.ContainsTextAttachment(attachments) {
-		return nil
-	}
-
-	m.textarea.Reset()
-	m.attachments = nil
-	// Change the placeholder when sending a new message.
-	m.randomizePlaceholders()
-
-	return tea.Batch(
-		util.CmdHandler(chat.SendMsg{
-			Text:        value,
-			Attachments: attachments,
-		}),
-	)
-}
-
-func (m *editorCmp) repositionCompletions() tea.Msg {
-	x, y := m.completionsPosition()
-	return completions.RepositionCompletionsMsg{X: x, Y: y}
-}
-
-func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case chat.SessionClearedMsg:
-		m.session = session.Session{}
-		m.sessionFileReads = nil
-	case tea.WindowSizeMsg:
-		return m, m.repositionCompletions
-	case filepicker.FilePickedMsg:
-		m.attachments = append(m.attachments, msg.Attachment)
-		return m, nil
-	case completions.CompletionsOpenedMsg:
-		m.isCompletionsOpen = true
-	case completions.CompletionsClosedMsg:
-		m.isCompletionsOpen = false
-		m.currentQuery = ""
-		m.completionsStartIndex = 0
-	case completions.SelectCompletionMsg:
-		if !m.isCompletionsOpen {
-			return m, nil
-		}
-		if item, ok := msg.Value.(FileCompletionItem); ok {
-			word := m.textarea.Word()
-			// If the selected item is a file, insert its path into the textarea
-			value := m.textarea.Value()
-			value = value[:m.completionsStartIndex] + // Remove the current query
-				item.Path + // Insert the file path
-				value[m.completionsStartIndex+len(word):] // Append the rest of the value
-			// XXX: This will always move the cursor to the end of the textarea.
-			m.textarea.SetValue(value)
-			m.textarea.MoveToEnd()
-			if !msg.Insert {
-				m.isCompletionsOpen = false
-				m.currentQuery = ""
-				m.completionsStartIndex = 0
-			}
-			absPath, _ := filepath.Abs(item.Path)
-
-			ctx := context.Background()
-
-			// Skip attachment if file was already read and hasn't been modified.
-			if m.session.ID != "" {
-				lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
-				if !lastRead.IsZero() {
-					if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
-						return m, nil
-					}
-				}
-			} else if slices.Contains(m.sessionFileReads, absPath) {
-				return m, nil
-			}
-
-			m.sessionFileReads = append(m.sessionFileReads, absPath)
-			content, err := os.ReadFile(item.Path)
-			if err != nil {
-				// if it fails, let the LLM handle it later.
-				return m, nil
-			}
-			m.attachments = append(m.attachments, message.Attachment{
-				FilePath: item.Path,
-				FileName: filepath.Base(item.Path),
-				MimeType: mimeOf(content),
-				Content:  content,
-			})
-		}
-
-	case commands.OpenExternalEditorMsg:
-		if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
-			return m, util.ReportWarn("Agent is working, please wait...")
-		}
-		return m, m.openEditor(m.textarea.Value())
-	case OpenEditorMsg:
-		m.textarea.SetValue(msg.Text)
-		m.textarea.MoveToEnd()
-	case tea.PasteMsg:
-		if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
-			content := []byte(msg.Content)
-			if len(content) > maxAttachmentSize {
-				return m, util.ReportWarn("Paste is too big (>5mb)")
-			}
-			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
-			mimeType := mimeOf(content)
-			attachment := message.Attachment{
-				FileName: name,
-				FilePath: name,
-				MimeType: mimeType,
-				Content:  content,
-			}
-			return m, util.CmdHandler(filepicker.FilePickedMsg{
-				Attachment: attachment,
-			})
-		}
-
-		// Try to parse as a file path.
-		content, path, err := filepathToFile(msg.Content)
-		if err != nil {
-			// Not a file path, just update the textarea normally.
-			m.textarea, cmd = m.textarea.Update(msg)
-			return m, cmd
-		}
-
-		if len(content) > maxAttachmentSize {
-			return m, util.ReportWarn("File is too big (>5mb)")
-		}
-
-		mimeType := mimeOf(content)
-		attachment := message.Attachment{
-			FilePath: path,
-			FileName: filepath.Base(path),
-			MimeType: mimeType,
-			Content:  content,
-		}
-		if !attachment.IsText() && !attachment.IsImage() {
-			return m, util.ReportWarn("Invalid file content type: " + mimeType)
-		}
-		return m, util.CmdHandler(filepicker.FilePickedMsg{
-			Attachment: attachment,
-		})
-
-	case commands.ToggleYoloModeMsg:
-		m.setEditorPrompt()
-		return m, nil
-	case tea.KeyPressMsg:
-		cur := m.textarea.Cursor()
-		curIdx := m.textarea.Width()*cur.Y + cur.X
-		switch {
-		// Open command palette when "/" is pressed on empty prompt
-		case msg.String() == "/" && m.IsEmpty():
-			return m, util.CmdHandler(dialogs.OpenDialogMsg{
-				Model: commands.NewCommandDialog(m.session.ID),
-			})
-		// Completions
-		case msg.String() == "@" && !m.isCompletionsOpen &&
-			// only show if beginning of prompt, or if previous char is a space or newline:
-			(len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
-			m.isCompletionsOpen = true
-			m.currentQuery = ""
-			m.completionsStartIndex = curIdx
-			cmds = append(cmds, m.startCompletions)
-		case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
-			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
-		}
-		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
-			m.deleteMode = true
-			return m, nil
-		}
-		if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
-			m.deleteMode = false
-			m.attachments = nil
-			return m, nil
-		}
-		rune := msg.Code
-		if m.deleteMode && unicode.IsDigit(rune) {
-			num := int(rune - '0')
-			m.deleteMode = false
-			if num < 10 && len(m.attachments) > num {
-				if num == 0 {
-					m.attachments = m.attachments[num+1:]
-				} else {
-					m.attachments = slices.Delete(m.attachments, num, num+1)
-				}
-				return m, nil
-			}
-		}
-		if key.Matches(msg, m.keyMap.OpenEditor) {
-			if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
-				return m, util.ReportWarn("Agent is working, please wait...")
-			}
-			return m, m.openEditor(m.textarea.Value())
-		}
-		if key.Matches(msg, DeleteKeyMaps.Escape) {
-			m.deleteMode = false
-			return m, nil
-		}
-		if key.Matches(msg, m.keyMap.Newline) {
-			m.textarea.InsertRune('\n')
-			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
-		}
-		// Handle image paste from clipboard
-		if key.Matches(msg, m.keyMap.PasteImage) {
-			imageData, err := readClipboard(clipboardFormatImage)
-
-			if err != nil || len(imageData) == 0 {
-				// If no image data found, try to get text data (could be file path)
-				var textData []byte
-				textData, err = readClipboard(clipboardFormatText)
-				if err != nil || len(textData) == 0 {
-					// If clipboard is empty, show a warning
-					return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.")
-				}
-
-				// Check if the text data is a file path
-				textStr := string(textData)
-				// First, try to interpret as a file path (existing functionality)
-				path := strings.ReplaceAll(textStr, "\\ ", " ")
-				path, err = filepath.Abs(strings.TrimSpace(path))
-				if err == nil {
-					isAllowedType := false
-					for _, ext := range filepicker.AllowedTypes {
-						if strings.HasSuffix(path, ext) {
-							isAllowedType = true
-							break
-						}
-					}
-					if isAllowedType {
-						tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
-						if !tooBig {
-							content, err := os.ReadFile(path)
-							if err == nil {
-								mimeBufferSize := min(512, len(content))
-								mimeType := http.DetectContentType(content[:mimeBufferSize])
-								fileName := filepath.Base(path)
-								attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
-								return m, util.CmdHandler(filepicker.FilePickedMsg{
-									Attachment: attachment,
-								})
-							}
-						}
-					}
-				}
-
-				// If not a valid file path, show a warning
-				return m, util.ReportWarn("No image found in clipboard")
-			} else {
-				// We have image data from the clipboard
-				// Create a temporary file to store the clipboard image data
-				tempFile, err := os.CreateTemp("", "clipboard_image_crush_*")
-				if err != nil {
-					return m, util.ReportError(err)
-				}
-				defer tempFile.Close()
-
-				// Write clipboard content to the temporary file
-				_, err = tempFile.Write(imageData)
-				if err != nil {
-					return m, util.ReportError(err)
-				}
-
-				// Determine the file extension based on the image data
-				mimeBufferSize := min(512, len(imageData))
-				mimeType := http.DetectContentType(imageData[:mimeBufferSize])
-
-				// Create an attachment from the temporary file
-				fileName := filepath.Base(tempFile.Name())
-				attachment := message.Attachment{
-					FilePath: tempFile.Name(),
-					FileName: fileName,
-					MimeType: mimeType,
-					Content:  imageData,
-				}
-
-				return m, util.CmdHandler(filepicker.FilePickedMsg{
-					Attachment: attachment,
-				})
-			}
-		}
-		// Handle Enter key
-		if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
-			value := m.textarea.Value()
-			if strings.HasSuffix(value, "\\") {
-				// If the last character is a backslash, remove it and add a newline.
-				m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
-			} else {
-				// Otherwise, send the message
-				return m, m.send()
-			}
-		}
-	}
-
-	m.textarea, cmd = m.textarea.Update(msg)
-	cmds = append(cmds, cmd)
-
-	if m.textarea.Focused() {
-		kp, ok := msg.(tea.KeyPressMsg)
-		if ok {
-			if kp.String() == "space" || m.textarea.Value() == "" {
-				m.isCompletionsOpen = false
-				m.currentQuery = ""
-				m.completionsStartIndex = 0
-				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
-			} else {
-				word := m.textarea.Word()
-				if strings.HasPrefix(word, "@") {
-					// XXX: wont' work if editing in the middle of the field.
-					m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
-					m.currentQuery = word[1:]
-					x, y := m.completionsPosition()
-					x -= len(m.currentQuery)
-					m.isCompletionsOpen = true
-					cmds = append(cmds,
-						util.CmdHandler(completions.FilterCompletionsMsg{
-							Query:  m.currentQuery,
-							Reopen: m.isCompletionsOpen,
-							X:      x,
-							Y:      y,
-						}),
-					)
-				} else if m.isCompletionsOpen {
-					m.isCompletionsOpen = false
-					m.currentQuery = ""
-					m.completionsStartIndex = 0
-					cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
-				}
-			}
-		}
-	}
-
-	return m, tea.Batch(cmds...)
-}
-
-func (m *editorCmp) setEditorPrompt() {
-	if m.app.Permissions.SkipRequests() {
-		m.textarea.SetPromptFunc(4, yoloPromptFunc)
-		return
-	}
-	m.textarea.SetPromptFunc(4, normalPromptFunc)
-}
-
-func (m *editorCmp) completionsPosition() (int, int) {
-	cur := m.textarea.Cursor()
-	if cur == nil {
-		return m.x, m.y + 1 // adjust for padding
-	}
-	x := cur.X + m.x
-	y := cur.Y + m.y + 1 // adjust for padding
-	return x, y
-}
-
-func (m *editorCmp) Cursor() *tea.Cursor {
-	cursor := m.textarea.Cursor()
-	if cursor != nil {
-		cursor.X = cursor.X + m.x + 1
-		cursor.Y = cursor.Y + m.y + 1 // adjust for padding
-	}
-	return cursor
-}
-
-var readyPlaceholders = [...]string{
-	"Ready!",
-	"Ready...",
-	"Ready?",
-	"Ready for instructions",
-}
-
-var workingPlaceholders = [...]string{
-	"Working!",
-	"Working...",
-	"Brrrrr...",
-	"Prrrrrrrr...",
-	"Processing...",
-	"Thinking...",
-}
-
-func (m *editorCmp) randomizePlaceholders() {
-	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
-	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
-}
-
-func (m *editorCmp) View() string {
-	t := styles.CurrentTheme()
-	// Update placeholder
-	if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
-		m.textarea.Placeholder = m.workingPlaceholder
-	} else {
-		m.textarea.Placeholder = m.readyPlaceholder
-	}
-	if m.app.Permissions.SkipRequests() {
-		m.textarea.Placeholder = "Yolo mode!"
-	}
-	if len(m.attachments) == 0 {
-		return t.S().Base.Padding(1).Render(
-			m.textarea.View(),
-		)
-	}
-	return t.S().Base.Padding(0, 1, 1, 1).Render(
-		lipgloss.JoinVertical(
-			lipgloss.Top,
-			m.attachmentsContent(),
-			m.textarea.View(),
-		),
-	)
-}
-
-func (m *editorCmp) SetSize(width, height int) tea.Cmd {
-	m.width = width
-	m.height = height
-	m.textarea.SetWidth(width - 2)   // adjust for padding
-	m.textarea.SetHeight(height - 2) // adjust for padding
-	return nil
-}
-
-func (m *editorCmp) GetSize() (int, int) {
-	return m.textarea.Width(), m.textarea.Height()
-}
-
-func (m *editorCmp) attachmentsContent() string {
-	var styledAttachments []string
-	t := styles.CurrentTheme()
-	attachmentStyle := t.S().Base.
-		Padding(0, 1).
-		MarginRight(1).
-		Background(t.FgMuted).
-		Foreground(t.FgBase).
-		Render
-	iconStyle := t.S().Base.
-		Foreground(t.BgSubtle).
-		Background(t.Green).
-		Padding(0, 1).
-		Bold(true).
-		Render
-	rmStyle := t.S().Base.
-		Padding(0, 1).
-		Bold(true).
-		Background(t.Red).
-		Foreground(t.FgBase).
-		Render
-	for i, attachment := range m.attachments {
-		filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
-		icon := styles.ImageIcon
-		if attachment.IsText() {
-			icon = styles.TextIcon
-		}
-		if m.deleteMode {
-			styledAttachments = append(
-				styledAttachments,
-				rmStyle(fmt.Sprintf("%d", i)),
-				attachmentStyle(filename),
-			)
-			continue
-		}
-		styledAttachments = append(
-			styledAttachments,
-			iconStyle(icon),
-			attachmentStyle(filename),
-		)
-	}
-	return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
-}
-
-func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
-	m.x = x
-	m.y = y
-	return nil
-}
-
-func (m *editorCmp) startCompletions() tea.Msg {
-	ls := m.app.Config().Options.TUI.Completions
-	depth, limit := ls.Limits()
-	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
-	slices.Sort(files)
-	completionItems := make([]completions.Completion, 0, len(files))
-	for _, file := range files {
-		file = strings.TrimPrefix(file, "./")
-		completionItems = append(completionItems, completions.Completion{
-			Title: file,
-			Value: FileCompletionItem{
-				Path: file,
-			},
-		})
-	}
-
-	x, y := m.completionsPosition()
-	return completions.OpenCompletionsMsg{
-		Completions: completionItems,
-		X:           x,
-		Y:           y,
-		MaxResults:  maxFileResults,
-	}
-}
-
-// Blur implements Container.
-func (c *editorCmp) Blur() tea.Cmd {
-	c.textarea.Blur()
-	return nil
-}
-
-// Focus implements Container.
-func (c *editorCmp) Focus() tea.Cmd {
-	return c.textarea.Focus()
-}
-
-// IsFocused implements Container.
-func (c *editorCmp) IsFocused() bool {
-	return c.textarea.Focused()
-}
-
-// Bindings implements Container.
-func (c *editorCmp) Bindings() []key.Binding {
-	return c.keyMap.KeyBindings()
-}
-
-// TODO: most likely we do not need to have the session here
-// we need to move some functionality to the page level
-func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
-	c.session = session
-	for _, path := range c.sessionFileReads {
-		c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
-	}
-	return nil
-}
-
-func (c *editorCmp) IsCompletionsOpen() bool {
-	return c.isCompletionsOpen
-}
-
-func (c *editorCmp) HasAttachments() bool {
-	return len(c.attachments) > 0
-}
-
-func (c *editorCmp) IsEmpty() bool {
-	return strings.TrimSpace(c.textarea.Value()) == ""
-}
-
-func normalPromptFunc(info textarea.PromptInfo) string {
-	t := styles.CurrentTheme()
-	if info.LineNumber == 0 {
-		if info.Focused {
-			return "  > "
-		}
-		return "::: "
-	}
-	if info.Focused {
-		return t.S().Base.Foreground(t.GreenDark).Render("::: ")
-	}
-	return t.S().Muted.Render("::: ")
-}
-
-func yoloPromptFunc(info textarea.PromptInfo) string {
-	t := styles.CurrentTheme()
-	if info.LineNumber == 0 {
-		if info.Focused {
-			return fmt.Sprintf("%s ", t.YoloIconFocused)
-		} else {
-			return fmt.Sprintf("%s ", t.YoloIconBlurred)
-		}
-	}
-	if info.Focused {
-		return fmt.Sprintf("%s ", t.YoloDotsFocused)
-	}
-	return fmt.Sprintf("%s ", t.YoloDotsBlurred)
-}
-
-func New(app *app.App) Editor {
-	t := styles.CurrentTheme()
-	ta := textarea.New()
-	ta.SetStyles(t.S().TextArea)
-	ta.ShowLineNumbers = false
-	ta.CharLimit = -1
-	ta.SetVirtualCursor(false)
-	ta.Focus()
-	e := &editorCmp{
-		// TODO: remove the app instance from here
-		app:      app,
-		textarea: ta,
-		keyMap:   DefaultEditorKeyMap(),
-	}
-	e.setEditorPrompt()
-
-	e.randomizePlaceholders()
-	e.textarea.Placeholder = e.readyPlaceholder
-
-	return e
-}
-
-var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
-
-var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
-
-func (m *editorCmp) pasteIdx() int {
-	result := 0
-	for _, at := range m.attachments {
-		found := pasteRE.FindStringSubmatch(at.FileName)
-		if len(found) == 0 {
-			continue
-		}
-		idx, err := strconv.Atoi(found[1])
-		if err == nil {
-			result = max(result, idx)
-		}
-	}
-	return result + 1
-}
-
-func filepathToFile(name string) ([]byte, string, error) {
-	path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
-	if err != nil {
-		return nil, "", err
-	}
-	content, err := os.ReadFile(path)
-	if err != nil {
-		return nil, "", err
-	}
-	return content, path, nil
-}
-
-func mimeOf(content []byte) string {
-	mimeBufferSize := min(512, len(content))
-	return http.DetectContentType(content[:mimeBufferSize])
-}

internal/tui/components/chat/editor/keys.go 🔗

@@ -1,77 +0,0 @@
-package editor
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type EditorKeyMap struct {
-	AddFile     key.Binding
-	SendMessage key.Binding
-	OpenEditor  key.Binding
-	Newline     key.Binding
-	PasteImage  key.Binding
-}
-
-func DefaultEditorKeyMap() EditorKeyMap {
-	return EditorKeyMap{
-		AddFile: key.NewBinding(
-			key.WithKeys("/"),
-			key.WithHelp("/", "add file"),
-		),
-		SendMessage: key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "send"),
-		),
-		OpenEditor: key.NewBinding(
-			key.WithKeys("ctrl+o"),
-			key.WithHelp("ctrl+o", "open editor"),
-		),
-		Newline: key.NewBinding(
-			key.WithKeys("shift+enter", "ctrl+j"),
-			// "ctrl+j" is a common keybinding for newline in many editors. If
-			// the terminal supports "shift+enter", we substitute the help text
-			// to reflect that.
-			key.WithHelp("ctrl+j", "newline"),
-		),
-		PasteImage: key.NewBinding(
-			key.WithKeys("ctrl+v"),
-			key.WithHelp("ctrl+v", "paste image from clipboard"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k EditorKeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.AddFile,
-		k.SendMessage,
-		k.OpenEditor,
-		k.Newline,
-		k.PasteImage,
-		AttachmentsKeyMaps.AttachmentDeleteMode,
-		AttachmentsKeyMaps.DeleteAllAttachments,
-		AttachmentsKeyMaps.Escape,
-	}
-}
-
-type DeleteAttachmentKeyMaps struct {
-	AttachmentDeleteMode key.Binding
-	Escape               key.Binding
-	DeleteAllAttachments key.Binding
-}
-
-// TODO: update this to use the new keymap concepts
-var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
-	AttachmentDeleteMode: key.NewBinding(
-		key.WithKeys("ctrl+r"),
-		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc", "alt+esc"),
-		key.WithHelp("esc", "cancel delete mode"),
-	),
-	DeleteAllAttachments: key.NewBinding(
-		key.WithKeys("r"),
-		key.WithHelp("ctrl+r+r", "delete all attachments"),
-	),
-}

internal/tui/components/chat/header/header.go 🔗

@@ -1,160 +0,0 @@
-package header
-
-import (
-	"fmt"
-	"strings"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/x/ansi"
-)
-
-type Header interface {
-	util.Model
-	SetSession(session session.Session) tea.Cmd
-	SetWidth(width int) tea.Cmd
-	SetDetailsOpen(open bool)
-	ShowingDetails() bool
-}
-
-type header struct {
-	width       int
-	session     session.Session
-	lspClients  *csync.Map[string, *lsp.Client]
-	detailsOpen bool
-}
-
-func New(lspClients *csync.Map[string, *lsp.Client]) Header {
-	return &header{
-		lspClients: lspClients,
-		width:      0,
-	}
-}
-
-func (h *header) Init() tea.Cmd {
-	return nil
-}
-
-func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case pubsub.Event[session.Session]:
-		if msg.Type == pubsub.UpdatedEvent {
-			if h.session.ID == msg.Payload.ID {
-				h.session = msg.Payload
-			}
-		}
-	}
-	return h, nil
-}
-
-func (h *header) View() string {
-	if h.session.ID == "" {
-		return ""
-	}
-
-	const (
-		gap          = " "
-		diag         = "╱"
-		minDiags     = 3
-		leftPadding  = 1
-		rightPadding = 1
-	)
-
-	t := styles.CurrentTheme()
-
-	var b strings.Builder
-
-	b.WriteString(t.S().Base.Foreground(t.Secondary).Render("Charm™"))
-	b.WriteString(gap)
-	b.WriteString(styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary))
-	b.WriteString(gap)
-
-	availDetailWidth := h.width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minDiags
-	details := h.details(availDetailWidth)
-
-	remainingWidth := h.width -
-		lipgloss.Width(b.String()) -
-		lipgloss.Width(details) -
-		leftPadding -
-		rightPadding
-
-	if remainingWidth > 0 {
-		b.WriteString(t.S().Base.Foreground(t.Primary).Render(
-			strings.Repeat(diag, max(minDiags, remainingWidth)),
-		))
-		b.WriteString(gap)
-	}
-
-	b.WriteString(details)
-
-	return t.S().Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
-}
-
-func (h *header) details(availWidth int) string {
-	s := styles.CurrentTheme().S()
-
-	var parts []string
-
-	errorCount := 0
-	for l := range h.lspClients.Seq() {
-		errorCount += l.GetDiagnosticCounts().Error
-	}
-
-	if errorCount > 0 {
-		parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
-	}
-
-	agentCfg := config.Get().Agents[config.AgentCoder]
-	model := config.Get().GetModelByType(agentCfg.Model)
-	percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
-	formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
-	parts = append(parts, formattedPercentage)
-
-	const keystroke = "ctrl+d"
-	if h.detailsOpen {
-		parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" close"))
-	} else {
-		parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" open "))
-	}
-
-	dot := s.Subtle.Render(" • ")
-	metadata := strings.Join(parts, dot)
-	metadata = dot + metadata
-
-	// Truncate cwd if necessary, and insert it at the beginning.
-	const dirTrimLimit = 4
-	cwd := fsext.DirTrim(fsext.PrettyPath(config.Get().WorkingDir()), dirTrimLimit)
-	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
-	cwd = s.Muted.Render(cwd)
-
-	return cwd + metadata
-}
-
-func (h *header) SetDetailsOpen(open bool) {
-	h.detailsOpen = open
-}
-
-// SetSession implements Header.
-func (h *header) SetSession(session session.Session) tea.Cmd {
-	h.session = session
-	return nil
-}
-
-// SetWidth implements Header.
-func (h *header) SetWidth(width int) tea.Cmd {
-	h.width = width
-	return nil
-}
-
-// ShowingDetails implements Header.
-func (h *header) ShowingDetails() bool {
-	return h.detailsOpen
-}

internal/tui/components/chat/messages/messages.go 🔗

@@ -1,461 +0,0 @@
-package messages
-
-import (
-	"fmt"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/viewport"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/catwalk/pkg/catwalk"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/charmbracelet/x/exp/ordered"
-	"github.com/google/uuid"
-
-	"github.com/atotto/clipboard"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/tui/components/anim"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-// CopyKey is the key binding for copying message content to the clipboard.
-var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
-
-// ClearSelectionKey is the key binding for clearing the current selection in the chat interface.
-var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection"))
-
-// MessageCmp defines the interface for message components in the chat interface.
-// It combines standard UI model interfaces with message-specific functionality.
-type MessageCmp interface {
-	util.Model                      // Basic Bubble util.Model interface
-	layout.Sizeable                 // Width/height management
-	layout.Focusable                // Focus state management
-	GetMessage() message.Message    // Access to underlying message data
-	SetMessage(msg message.Message) // Update the message content
-	Spinning() bool                 // Animation state for loading messages
-	ID() string
-}
-
-// messageCmp implements the MessageCmp interface for displaying chat messages.
-// It handles rendering of user and assistant messages with proper styling,
-// animations, and state management.
-type messageCmp struct {
-	width   int  // Component width for text wrapping
-	focused bool // Focus state for border styling
-
-	// Core message data and state
-	message  message.Message // The underlying message content
-	spinning bool            // Whether to show loading animation
-	anim     *anim.Anim      // Animation component for loading states
-
-	// Thinking viewport for displaying reasoning content
-	thinkingViewport viewport.Model
-}
-
-var focusedMessageBorder = lipgloss.Border{
-	Left: "▌",
-}
-
-// NewMessageCmp creates a new message component with the given message and options
-func NewMessageCmp(msg message.Message) MessageCmp {
-	t := styles.CurrentTheme()
-
-	thinkingViewport := viewport.New()
-	thinkingViewport.SetHeight(1)
-	thinkingViewport.KeyMap = viewport.KeyMap{}
-
-	m := &messageCmp{
-		message: msg,
-		anim: anim.New(anim.Settings{
-			Size:        15,
-			GradColorA:  t.Primary,
-			GradColorB:  t.Secondary,
-			CycleColors: true,
-		}),
-		thinkingViewport: thinkingViewport,
-	}
-	return m
-}
-
-// Init initializes the message component and starts animations if needed.
-// Returns a command to start the animation for spinning messages.
-func (m *messageCmp) Init() tea.Cmd {
-	m.spinning = m.shouldSpin()
-	return m.anim.Init()
-}
-
-// Update handles incoming messages and updates the component state.
-// Manages animation updates for spinning messages and stops animation when appropriate.
-func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case anim.StepMsg:
-		m.spinning = m.shouldSpin()
-		if m.spinning {
-			u, cmd := m.anim.Update(msg)
-			m.anim = u.(*anim.Anim)
-			return m, cmd
-		}
-	case tea.KeyPressMsg:
-		if key.Matches(msg, CopyKey) {
-			return m, tea.Sequence(
-				tea.SetClipboard(m.message.Content().Text),
-				func() tea.Msg {
-					_ = clipboard.WriteAll(m.message.Content().Text)
-					return nil
-				},
-				util.ReportInfo("Message copied to clipboard"),
-			)
-		}
-	}
-	return m, nil
-}
-
-// View renders the message component based on its current state.
-// Returns different views for spinning, user, and assistant messages.
-func (m *messageCmp) View() string {
-	if m.spinning && m.message.ReasoningContent().Thinking == "" {
-		if m.message.IsSummaryMessage {
-			m.anim.SetLabel("Summarizing")
-		}
-		return m.style().PaddingLeft(1).Render(m.anim.View())
-	}
-	if m.message.ID != "" {
-		// this is a user or assistant message
-		switch m.message.Role {
-		case message.User:
-			return m.renderUserMessage()
-		default:
-			return m.renderAssistantMessage()
-		}
-	}
-	return m.style().Render("No message content")
-}
-
-// GetMessage returns the underlying message data
-func (m *messageCmp) GetMessage() message.Message {
-	return m.message
-}
-
-func (m *messageCmp) SetMessage(msg message.Message) {
-	m.message = msg
-}
-
-// textWidth calculates the available width for text content,
-// accounting for borders and padding
-func (m *messageCmp) textWidth() int {
-	return m.width - 2 // take into account the border and/or padding
-}
-
-// style returns the lipgloss style for the message component.
-// Applies different border colors and styles based on message role and focus state.
-func (msg *messageCmp) style() lipgloss.Style {
-	t := styles.CurrentTheme()
-	borderStyle := lipgloss.NormalBorder()
-	if msg.focused {
-		borderStyle = focusedMessageBorder
-	}
-
-	style := t.S().Text
-	if msg.message.Role == message.User {
-		style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
-	} else {
-		if msg.focused {
-			style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
-		} else {
-			style = style.PaddingLeft(2)
-		}
-	}
-	return style
-}
-
-// renderAssistantMessage renders assistant messages with optional footer information.
-// Shows model name, response time, and finish reason when the message is complete.
-func (m *messageCmp) renderAssistantMessage() string {
-	t := styles.CurrentTheme()
-	parts := []string{}
-	content := strings.TrimSpace(m.message.Content().String())
-	thinking := m.message.IsThinking()
-	thinkingContent := strings.TrimSpace(m.message.ReasoningContent().Thinking)
-	finished := m.message.IsFinished()
-	finishedData := m.message.FinishPart()
-
-	if thinking || thinkingContent != "" {
-		m.anim.SetLabel("Thinking")
-		thinkingContent = m.renderThinkingContent()
-	} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
-		// Don't render empty assistant messages with EndTurn
-		return ""
-	} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
-		content = "*Canceled*"
-	} else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
-		errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
-		truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
-		title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
-		details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
-		errorContent := fmt.Sprintf("%s\n\n%s", title, details)
-		return m.style().Render(errorContent)
-	}
-
-	if thinkingContent != "" {
-		parts = append(parts, thinkingContent)
-	}
-
-	if content != "" {
-		if thinkingContent != "" {
-			parts = append(parts, "")
-		}
-		parts = append(parts, m.toMarkdown(content))
-	}
-
-	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
-	return m.style().Render(joined)
-}
-
-// renderUserMessage renders user messages with file attachments. It displays
-// message content and any attached files with appropriate icons.
-func (m *messageCmp) renderUserMessage() string {
-	t := styles.CurrentTheme()
-	var parts []string
-
-	if s := m.message.Content().String(); s != "" {
-		parts = append(parts, m.toMarkdown(s))
-	}
-
-	attachmentStyle := t.S().Base.
-		Padding(0, 1).
-		MarginRight(1).
-		Background(t.FgMuted).
-		Foreground(t.FgBase).
-		Render
-	iconStyle := t.S().Base.
-		Foreground(t.BgSubtle).
-		Background(t.Green).
-		Padding(0, 1).
-		Bold(true).
-		Render
-
-	attachments := make([]string, len(m.message.BinaryContent()))
-	for i, attachment := range m.message.BinaryContent() {
-		const maxFilenameWidth = 10
-		filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...")
-		icon := styles.ImageIcon
-		if strings.HasPrefix(attachment.MIMEType, "text/") {
-			icon = styles.TextIcon
-		}
-		attachments[i] = lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			iconStyle(icon),
-			attachmentStyle(filename),
-		)
-	}
-
-	if len(attachments) > 0 {
-		parts = append(parts, strings.Join(attachments, ""))
-	}
-
-	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
-	return m.style().Render(joined)
-}
-
-// toMarkdown converts text content to rendered markdown using the configured renderer
-func (m *messageCmp) toMarkdown(content string) string {
-	r := styles.GetMarkdownRenderer(m.textWidth())
-	rendered, _ := r.Render(content)
-	return strings.TrimSuffix(rendered, "\n")
-}
-
-func (m *messageCmp) renderThinkingContent() string {
-	t := styles.CurrentTheme()
-	reasoningContent := m.message.ReasoningContent()
-	if strings.TrimSpace(reasoningContent.Thinking) == "" {
-		return ""
-	}
-
-	width := m.textWidth() - 2
-	width = min(width, 120)
-
-	renderer := styles.GetPlainMarkdownRenderer(width - 1)
-	rendered, err := renderer.Render(reasoningContent.Thinking)
-	if err != nil {
-		lines := strings.Split(reasoningContent.Thinking, "\n")
-		var content strings.Builder
-		lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
-		for i, line := range lines {
-			if line == "" {
-				continue
-			}
-			content.WriteString(lineStyle.Width(width).Render(line))
-			if i < len(lines)-1 {
-				content.WriteString("\n")
-			}
-		}
-		rendered = content.String()
-	}
-
-	fullContent := strings.TrimSpace(rendered)
-	height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
-	m.thinkingViewport.SetHeight(height)
-	m.thinkingViewport.SetWidth(m.textWidth())
-	m.thinkingViewport.SetContent(fullContent)
-	m.thinkingViewport.GotoBottom()
-	finishReason := m.message.FinishPart()
-	var footer string
-	if reasoningContent.StartedAt > 0 {
-		duration := m.message.ThinkingDuration()
-		if reasoningContent.FinishedAt > 0 {
-			m.anim.SetLabel("")
-			opts := core.StatusOpts{
-				Title:       "Thought for",
-				Description: duration.String(),
-			}
-			if duration.String() != "0s" {
-				footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
-			}
-		} else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
-			footer = t.S().Base.PaddingLeft(1).Render(m.toMarkdown("*Canceled*"))
-		} else {
-			footer = m.anim.View()
-		}
-	}
-	lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
-	result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View())
-	if footer != "" {
-		result += "\n\n" + footer
-	}
-	return result
-}
-
-// shouldSpin determines whether the message should show a loading animation.
-// Only assistant messages without content that aren't finished should spin.
-func (m *messageCmp) shouldSpin() bool {
-	if m.message.Role != message.Assistant {
-		return false
-	}
-
-	if m.message.IsFinished() {
-		return false
-	}
-
-	if strings.TrimSpace(m.message.Content().Text) != "" {
-		return false
-	}
-	if len(m.message.ToolCalls()) > 0 {
-		return false
-	}
-	return true
-}
-
-// Blur removes focus from the message component
-func (m *messageCmp) Blur() tea.Cmd {
-	m.focused = false
-	return nil
-}
-
-// Focus sets focus on the message component
-func (m *messageCmp) Focus() tea.Cmd {
-	m.focused = true
-	return nil
-}
-
-// IsFocused returns whether the message component is currently focused
-func (m *messageCmp) IsFocused() bool {
-	return m.focused
-}
-
-// Size management methods
-
-// GetSize returns the current dimensions of the message component
-func (m *messageCmp) GetSize() (int, int) {
-	return m.width, 0
-}
-
-// SetSize updates the width of the message component for text wrapping
-func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
-	m.width = ordered.Clamp(width, 1, 120)
-	m.thinkingViewport.SetWidth(m.width - 4)
-	return nil
-}
-
-// Spinning returns whether the message is currently showing a loading animation
-func (m *messageCmp) Spinning() bool {
-	return m.spinning
-}
-
-type AssistantSection interface {
-	list.Item
-	layout.Sizeable
-}
-type assistantSectionModel struct {
-	width               int
-	id                  string
-	message             message.Message
-	lastUserMessageTime time.Time
-}
-
-// ID implements AssistantSection.
-func (m *assistantSectionModel) ID() string {
-	return m.id
-}
-
-func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
-	return &assistantSectionModel{
-		width:               0,
-		id:                  uuid.NewString(),
-		message:             message,
-		lastUserMessageTime: lastUserMessageTime,
-	}
-}
-
-func (m *assistantSectionModel) Init() tea.Cmd {
-	return nil
-}
-
-func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
-	return m, nil
-}
-
-func (m *assistantSectionModel) View() string {
-	t := styles.CurrentTheme()
-	finishData := m.message.FinishPart()
-	finishTime := time.Unix(finishData.Time, 0)
-	duration := finishTime.Sub(m.lastUserMessageTime)
-	infoMsg := t.S().Subtle.Render(duration.String())
-	icon := t.S().Subtle.Render(styles.ModelIcon)
-	model := config.Get().GetModel(m.message.Provider, m.message.Model)
-	if model == nil {
-		// This means the model is not configured anymore
-		model = &catwalk.Model{
-			Name: "Unknown Model",
-		}
-	}
-	modelFormatted := t.S().Muted.Render(model.Name)
-	assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
-	return t.S().Base.PaddingLeft(2).Render(
-		core.Section(assistant, m.width-2),
-	)
-}
-
-func (m *assistantSectionModel) GetSize() (int, int) {
-	return m.width, 1
-}
-
-func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
-	m.width = width
-	return nil
-}
-
-func (m *assistantSectionModel) IsSectionHeader() bool {
-	return true
-}
-
-func (m *messageCmp) ID() string {
-	return m.message.ID
-}

internal/tui/components/chat/messages/renderer.go 🔗

@@ -1,1403 +0,0 @@
-package messages
-
-import (
-	"cmp"
-	"encoding/json"
-	"fmt"
-	"strings"
-	"time"
-
-	"charm.land/lipgloss/v2"
-	"charm.land/lipgloss/v2/tree"
-	"github.com/charmbracelet/crush/internal/agent"
-	"github.com/charmbracelet/crush/internal/agent/tools"
-	"github.com/charmbracelet/crush/internal/ansiext"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/todos"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/highlight"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/x/ansi"
-)
-
-// responseContextHeight limits the number of lines displayed in tool output
-const responseContextHeight = 10
-
-// renderer defines the interface for tool-specific rendering implementations
-type renderer interface {
-	// Render returns the complete (already styled) tool‑call view, not
-	// including the outer border.
-	Render(v *toolCallCmp) string
-}
-
-// rendererFactory creates new renderer instances
-type rendererFactory func() renderer
-
-// renderRegistry manages the mapping of tool names to their renderers
-type renderRegistry map[string]rendererFactory
-
-// register adds a new renderer factory to the registry
-func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
-
-// lookup retrieves a renderer for the given tool name, falling back to generic renderer
-func (rr renderRegistry) lookup(name string) renderer {
-	if f, ok := rr[name]; ok {
-		return f()
-	}
-	return genericRenderer{} // sensible fallback
-}
-
-// registry holds all registered tool renderers
-var registry = renderRegistry{}
-
-// baseRenderer provides common functionality for all tool renderers
-type baseRenderer struct{}
-
-func (br baseRenderer) Render(v *toolCallCmp) string {
-	if v.result.Data != "" {
-		if strings.HasPrefix(v.result.MIMEType, "image/") {
-			return br.renderWithParams(v, v.call.Name, nil, func() string {
-				return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
-			})
-		}
-		return br.renderWithParams(v, v.call.Name, nil, func() string {
-			return renderMediaContent(v, v.result.MIMEType, v.result.Content)
-		})
-	}
-
-	return br.renderWithParams(v, v.call.Name, nil, func() string {
-		return renderPlainContent(v, v.result.Content)
-	})
-}
-
-// paramBuilder helps construct parameter lists for tool headers
-type paramBuilder struct {
-	args []string
-}
-
-// newParamBuilder creates a new parameter builder
-func newParamBuilder() *paramBuilder {
-	return &paramBuilder{args: make([]string, 0)}
-}
-
-// addMain adds the main parameter (first argument)
-func (pb *paramBuilder) addMain(value string) *paramBuilder {
-	if value != "" {
-		pb.args = append(pb.args, value)
-	}
-	return pb
-}
-
-// addKeyValue adds a key-value pair parameter
-func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
-	if value != "" {
-		pb.args = append(pb.args, key, value)
-	}
-	return pb
-}
-
-// addFlag adds a boolean flag parameter
-func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
-	if value {
-		pb.args = append(pb.args, key, "true")
-	}
-	return pb
-}
-
-// build returns the final parameter list
-func (pb *paramBuilder) build() []string {
-	return pb.args
-}
-
-// renderWithParams provides a common rendering pattern for tools with parameters
-func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
-	width := v.textWidth()
-	if v.isNested {
-		width -= 4 // Adjust for nested tool call indentation
-	}
-	header := br.makeHeader(v, toolName, width, args...)
-	if v.isNested {
-		return v.style().Render(header)
-	}
-	if res, done := earlyState(header, v); done {
-		return res
-	}
-	body := contentRenderer()
-	return joinHeaderBody(header, body)
-}
-
-// unmarshalParams safely unmarshal JSON parameters
-func (br baseRenderer) unmarshalParams(input string, target any) error {
-	return json.Unmarshal([]byte(input), target)
-}
-
-// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
-func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
-	t := styles.CurrentTheme()
-	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
-	if v.result.ToolCallID != "" {
-		if v.result.IsError {
-			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
-		} else {
-			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
-		}
-	} else if v.cancelled {
-		icon = t.S().Muted.Render(styles.ToolPending)
-	}
-	tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool)
-	prefix := fmt.Sprintf("%s %s ", icon, tool)
-	return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...)
-}
-
-// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
-func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
-	if v.isNested {
-		return br.makeNestedHeader(v, tool, width, params...)
-	}
-	t := styles.CurrentTheme()
-	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
-	if v.result.ToolCallID != "" {
-		if v.result.IsError {
-			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
-		} else {
-			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
-		}
-	} else if v.cancelled {
-		icon = t.S().Muted.Render(styles.ToolPending)
-	}
-	tool = t.S().Base.Foreground(t.Blue).Render(tool)
-	prefix := fmt.Sprintf("%s %s ", icon, tool)
-	return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
-}
-
-// renderError provides consistent error rendering
-func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
-	t := styles.CurrentTheme()
-	header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
-	errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
-	message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
-	return joinHeaderBody(header, errorTag+" "+message)
-}
-
-// Register tool renderers
-func init() {
-	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
-	registry.register(tools.JobOutputToolName, func() renderer { return bashOutputRenderer{} })
-	registry.register(tools.JobKillToolName, func() renderer { return bashKillRenderer{} })
-	registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
-	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
-	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
-	registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
-	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
-	registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} })
-	registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} })
-	registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} })
-	registry.register(tools.WebSearchToolName, func() renderer { return webSearchRenderer{} })
-	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
-	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
-	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
-	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
-	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
-	registry.register(tools.TodosToolName, func() renderer { return todosRenderer{} })
-	registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
-}
-
-// -----------------------------------------------------------------------------
-//  Generic renderer
-// -----------------------------------------------------------------------------
-
-// genericRenderer handles unknown tool types with basic parameter display
-type genericRenderer struct {
-	baseRenderer
-}
-
-func (gr genericRenderer) Render(v *toolCallCmp) string {
-	if v.result.Data != "" {
-		if strings.HasPrefix(v.result.MIMEType, "image/") {
-			return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
-				return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
-			})
-		}
-		return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
-			return renderMediaContent(v, v.result.MIMEType, v.result.Content)
-		})
-	}
-
-	return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
-		return renderPlainContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Bash renderer
-// -----------------------------------------------------------------------------
-
-// bashRenderer handles bash command execution display
-type bashRenderer struct {
-	baseRenderer
-}
-
-// Render displays the bash command with sanitized newlines and plain output
-func (br bashRenderer) Render(v *toolCallCmp) string {
-	var params tools.BashParams
-	if err := br.unmarshalParams(v.call.Input, &params); err != nil {
-		return br.renderError(v, "Invalid bash parameters")
-	}
-
-	cmd := strings.ReplaceAll(params.Command, "\n", " ")
-	cmd = strings.ReplaceAll(cmd, "\t", "    ")
-	args := newParamBuilder().
-		addMain(cmd).
-		addFlag("background", params.RunInBackground).
-		build()
-	if v.call.Finished {
-		var meta tools.BashResponseMetadata
-		_ = br.unmarshalParams(v.result.Metadata, &meta)
-		if meta.Background {
-			description := cmp.Or(meta.Description, params.Command)
-			width := v.textWidth()
-			if v.isNested {
-				width -= 4 // Adjust for nested tool call indentation
-			}
-			header := makeJobHeader(v, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
-			if v.isNested {
-				return v.style().Render(header)
-			}
-			if res, done := earlyState(header, v); done {
-				return res
-			}
-			content := "Command: " + params.Command + "\n" + v.result.Content
-			body := renderPlainContent(v, content)
-			return joinHeaderBody(header, body)
-		}
-	}
-
-	return br.renderWithParams(v, "Bash", args, func() string {
-		var meta tools.BashResponseMetadata
-		if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil {
-			return renderPlainContent(v, v.result.Content)
-		}
-		// for backwards compatibility with older tool calls.
-		if meta.Output == "" && v.result.Content != tools.BashNoOutput {
-			meta.Output = v.result.Content
-		}
-
-		if meta.Output == "" {
-			return ""
-		}
-		return renderPlainContent(v, meta.Output)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Bash Output renderer
-// -----------------------------------------------------------------------------
-
-func makeJobHeader(v *toolCallCmp, subcommand, pid, description string, width int) string {
-	t := styles.CurrentTheme()
-	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
-	if v.result.ToolCallID != "" {
-		if v.result.IsError {
-			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
-		} else {
-			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
-		}
-	} else if v.cancelled {
-		icon = t.S().Muted.Render(styles.ToolPending)
-	}
-
-	jobPart := t.S().Base.Foreground(t.Blue).Render("Job")
-	subcommandPart := t.S().Base.Foreground(t.BlueDark).Render("(" + subcommand + ")")
-	pidPart := t.S().Muted.Render(pid)
-	descPart := ""
-	if description != "" {
-		descPart = " " + t.S().Subtle.Render(description)
-	}
-
-	// Build the complete header
-	prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, subcommandPart, pidPart)
-	fullHeader := prefix + descPart
-
-	// Truncate if needed
-	if lipgloss.Width(fullHeader) > width {
-		availableWidth := width - lipgloss.Width(prefix) - 1 // -1 for space
-		if availableWidth < 10 {
-			// Not enough space for description, just show prefix
-			return prefix
-		}
-		descPart = " " + t.S().Subtle.Render(ansi.Truncate(description, availableWidth, "…"))
-		fullHeader = prefix + descPart
-	}
-
-	return fullHeader
-}
-
-// bashOutputRenderer handles bash output retrieval display
-type bashOutputRenderer struct {
-	baseRenderer
-}
-
-// Render displays the shell ID and output from a background shell
-func (bor bashOutputRenderer) Render(v *toolCallCmp) string {
-	var params tools.JobOutputParams
-	if err := bor.unmarshalParams(v.call.Input, &params); err != nil {
-		return bor.renderError(v, "Invalid job_output parameters")
-	}
-
-	var meta tools.JobOutputResponseMetadata
-	var description string
-	if v.result.Metadata != "" {
-		if err := bor.unmarshalParams(v.result.Metadata, &meta); err == nil {
-			if meta.Description != "" {
-				description = meta.Description
-			} else {
-				description = meta.Command
-			}
-		}
-	}
-
-	width := v.textWidth()
-	if v.isNested {
-		width -= 4 // Adjust for nested tool call indentation
-	}
-	header := makeJobHeader(v, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
-	if v.isNested {
-		return v.style().Render(header)
-	}
-	if res, done := earlyState(header, v); done {
-		return res
-	}
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
-}
-
-// -----------------------------------------------------------------------------
-//  Bash Kill renderer
-// -----------------------------------------------------------------------------
-
-// bashKillRenderer handles bash process termination display
-type bashKillRenderer struct {
-	baseRenderer
-}
-
-// Render displays the shell ID being terminated
-func (bkr bashKillRenderer) Render(v *toolCallCmp) string {
-	var params tools.JobKillParams
-	if err := bkr.unmarshalParams(v.call.Input, &params); err != nil {
-		return bkr.renderError(v, "Invalid job_kill parameters")
-	}
-
-	var meta tools.JobKillResponseMetadata
-	var description string
-	if v.result.Metadata != "" {
-		if err := bkr.unmarshalParams(v.result.Metadata, &meta); err == nil {
-			if meta.Description != "" {
-				description = meta.Description
-			} else {
-				description = meta.Command
-			}
-		}
-	}
-
-	width := v.textWidth()
-	if v.isNested {
-		width -= 4 // Adjust for nested tool call indentation
-	}
-	header := makeJobHeader(v, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
-	if v.isNested {
-		return v.style().Render(header)
-	}
-	if res, done := earlyState(header, v); done {
-		return res
-	}
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
-}
-
-// -----------------------------------------------------------------------------
-//  View renderer
-// -----------------------------------------------------------------------------
-
-// viewRenderer handles file viewing with syntax highlighting and line numbers
-type viewRenderer struct {
-	baseRenderer
-}
-
-// Render displays file content with optional limit and offset parameters
-func (vr viewRenderer) Render(v *toolCallCmp) string {
-	var params tools.ViewParams
-	if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
-		return vr.renderError(v, "Invalid view parameters")
-	}
-
-	file := fsext.PrettyPath(params.FilePath)
-	args := newParamBuilder().
-		addMain(file).
-		addKeyValue("limit", formatNonZero(params.Limit)).
-		addKeyValue("offset", formatNonZero(params.Offset)).
-		build()
-
-	return vr.renderWithParams(v, "View", args, func() string {
-		if v.result.Data != "" && strings.HasPrefix(v.result.MIMEType, "image/") {
-			return renderImageContent(v, v.result.Data, v.result.MIMEType, "")
-		}
-
-		var meta tools.ViewResponseMetadata
-		if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
-			return renderPlainContent(v, v.result.Content)
-		}
-		return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
-	})
-}
-
-// formatNonZero returns string representation of non-zero integers, empty string for zero
-func formatNonZero(value int) string {
-	if value == 0 {
-		return ""
-	}
-	return fmt.Sprintf("%d", value)
-}
-
-// -----------------------------------------------------------------------------
-//  Edit renderer
-// -----------------------------------------------------------------------------
-
-// editRenderer handles file editing with diff visualization
-type editRenderer struct {
-	baseRenderer
-}
-
-// Render displays the edited file with a formatted diff of changes
-func (er editRenderer) Render(v *toolCallCmp) string {
-	t := styles.CurrentTheme()
-	var params tools.EditParams
-	var args []string
-	if err := er.unmarshalParams(v.call.Input, &params); err == nil {
-		file := fsext.PrettyPath(params.FilePath)
-		args = newParamBuilder().addMain(file).build()
-	}
-
-	return er.renderWithParams(v, "Edit", args, func() string {
-		var meta tools.EditResponseMetadata
-		if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
-			return renderPlainContent(v, v.result.Content)
-		}
-
-		formatter := core.DiffFormatter().
-			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
-			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
-			Width(v.textWidth() - 2) // -2 for padding
-		if v.textWidth() > 120 {
-			formatter = formatter.Split()
-		}
-		// add a message to the bottom if the content was truncated
-		formatted := formatter.String()
-		if lipgloss.Height(formatted) > responseContextHeight {
-			contentLines := strings.Split(formatted, "\n")
-			truncateMessage := t.S().Muted.
-				Background(t.BgBaseLighter).
-				PaddingLeft(2).
-				Width(v.textWidth() - 2).
-				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
-			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
-		}
-		return formatted
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Multi-Edit renderer
-// -----------------------------------------------------------------------------
-
-// multiEditRenderer handles multiple file edits with diff visualization
-type multiEditRenderer struct {
-	baseRenderer
-}
-
-// Render displays the multi-edited file with a formatted diff of changes
-func (mer multiEditRenderer) Render(v *toolCallCmp) string {
-	t := styles.CurrentTheme()
-	var params tools.MultiEditParams
-	var args []string
-	if err := mer.unmarshalParams(v.call.Input, &params); err == nil {
-		file := fsext.PrettyPath(params.FilePath)
-		editsCount := len(params.Edits)
-		args = newParamBuilder().
-			addMain(file).
-			addKeyValue("edits", fmt.Sprintf("%d", editsCount)).
-			build()
-	}
-
-	return mer.renderWithParams(v, "Multi-Edit", args, func() string {
-		var meta tools.MultiEditResponseMetadata
-		if err := mer.unmarshalParams(v.result.Metadata, &meta); err != nil {
-			return renderPlainContent(v, v.result.Content)
-		}
-
-		formatter := core.DiffFormatter().
-			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
-			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
-			Width(v.textWidth() - 2) // -2 for padding
-		if v.textWidth() > 120 {
-			formatter = formatter.Split()
-		}
-		// add a message to the bottom if the content was truncated
-		formatted := formatter.String()
-		if lipgloss.Height(formatted) > responseContextHeight {
-			contentLines := strings.Split(formatted, "\n")
-			truncateMessage := t.S().Muted.
-				Background(t.BgBaseLighter).
-				PaddingLeft(2).
-				Width(v.textWidth() - 4).
-				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
-			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
-		}
-
-		// Add failed edits warning if any exist
-		if len(meta.EditsFailed) > 0 {
-			noteTag := t.S().Base.Padding(0, 2).Background(t.Info).Foreground(t.White).Render("Note")
-			noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits))
-			note := t.S().Base.
-				Width(v.textWidth() - 2).
-				Render(fmt.Sprintf("%s %s", noteTag, t.S().Muted.Render(noteMsg)))
-			formatted = lipgloss.JoinVertical(lipgloss.Left, formatted, "", note)
-		}
-
-		return formatted
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Write renderer
-// -----------------------------------------------------------------------------
-
-// writeRenderer handles file writing with syntax-highlighted content preview
-type writeRenderer struct {
-	baseRenderer
-}
-
-// Render displays the file being written with syntax highlighting
-func (wr writeRenderer) Render(v *toolCallCmp) string {
-	var params tools.WriteParams
-	var args []string
-	var file string
-	if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
-		file = fsext.PrettyPath(params.FilePath)
-		args = newParamBuilder().addMain(file).build()
-	}
-
-	return wr.renderWithParams(v, "Write", args, func() string {
-		return renderCodeContent(v, file, params.Content, 0)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Fetch renderer
-// -----------------------------------------------------------------------------
-
-// simpleFetchRenderer handles URL fetching with format-specific content display
-type simpleFetchRenderer struct {
-	baseRenderer
-}
-
-// Render displays the fetched URL with format and timeout parameters
-func (fr simpleFetchRenderer) Render(v *toolCallCmp) string {
-	var params tools.FetchParams
-	var args []string
-	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
-		args = newParamBuilder().
-			addMain(params.URL).
-			addKeyValue("format", params.Format).
-			addKeyValue("timeout", formatTimeout(params.Timeout)).
-			build()
-	}
-
-	return fr.renderWithParams(v, "Fetch", args, func() string {
-		file := fr.getFileExtension(params.Format)
-		return renderCodeContent(v, file, v.result.Content, 0)
-	})
-}
-
-// getFileExtension returns appropriate file extension for syntax highlighting
-func (fr simpleFetchRenderer) getFileExtension(format string) string {
-	switch format {
-	case "text":
-		return "fetch.txt"
-	case "html":
-		return "fetch.html"
-	default:
-		return "fetch.md"
-	}
-}
-
-// -----------------------------------------------------------------------------
-//  Agentic fetch renderer
-// -----------------------------------------------------------------------------
-
-// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls
-type agenticFetchRenderer struct {
-	baseRenderer
-}
-
-// Render displays the fetched URL or web search with prompt parameter and nested tool calls
-func (fr agenticFetchRenderer) Render(v *toolCallCmp) string {
-	t := styles.CurrentTheme()
-	var params tools.AgenticFetchParams
-	var args []string
-	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
-		if params.URL != "" {
-			args = newParamBuilder().
-				addMain(params.URL).
-				build()
-		}
-	}
-
-	prompt := params.Prompt
-	prompt = strings.ReplaceAll(prompt, "\n", " ")
-
-	header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...)
-	if res, done := earlyState(header, v); v.cancelled && done {
-		return res
-	}
-
-	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt")
-	remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1)
-	remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1))
-	prompt = t.S().Base.Width(remainingWidth).Render(prompt)
-	header = lipgloss.JoinVertical(
-		lipgloss.Left,
-		header,
-		"",
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			taskTag,
-			" ",
-			prompt,
-		),
-	)
-	childTools := tree.Root(header)
-
-	for _, call := range v.nestedToolCalls {
-		call.SetSize(remainingWidth, 1)
-		childTools.Child(call.View())
-	}
-	parts := []string{
-		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
-	}
-
-	if v.result.ToolCallID == "" {
-		v.spinning = true
-		parts = append(parts, "", v.anim.View())
-	} else {
-		v.spinning = false
-	}
-
-	header = lipgloss.JoinVertical(
-		lipgloss.Left,
-		parts...,
-	)
-
-	if v.result.ToolCallID == "" {
-		return header
-	}
-	body := renderMarkdownContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
-}
-
-// formatTimeout converts timeout seconds to duration string
-func formatTimeout(timeout int) string {
-	if timeout == 0 {
-		return ""
-	}
-	return (time.Duration(timeout) * time.Second).String()
-}
-
-// -----------------------------------------------------------------------------
-//  Web fetch renderer
-// -----------------------------------------------------------------------------
-
-// webFetchRenderer handles web page fetching with simplified URL display
-type webFetchRenderer struct {
-	baseRenderer
-}
-
-// Render displays a compact view of web_fetch with just the URL in a link style
-func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
-	var params tools.WebFetchParams
-	var args []string
-	if err := wfr.unmarshalParams(v.call.Input, &params); err == nil {
-		args = newParamBuilder().
-			addMain(params.URL).
-			build()
-	}
-
-	return wfr.renderWithParams(v, "Fetch", args, func() string {
-		return renderMarkdownContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Web search renderer
-// -----------------------------------------------------------------------------
-
-// webSearchRenderer handles web search with query display
-type webSearchRenderer struct {
-	baseRenderer
-}
-
-// Render displays a compact view of web_search with just the query
-func (wsr webSearchRenderer) Render(v *toolCallCmp) string {
-	var params tools.WebSearchParams
-	var args []string
-	if err := wsr.unmarshalParams(v.call.Input, &params); err == nil {
-		args = newParamBuilder().
-			addMain(params.Query).
-			build()
-	}
-
-	return wsr.renderWithParams(v, "Search", args, func() string {
-		return renderMarkdownContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Download renderer
-// -----------------------------------------------------------------------------
-
-// downloadRenderer handles file downloading with URL and file path display
-type downloadRenderer struct {
-	baseRenderer
-}
-
-// Render displays the download URL and destination file path with timeout parameter
-func (dr downloadRenderer) Render(v *toolCallCmp) string {
-	var params tools.DownloadParams
-	var args []string
-	if err := dr.unmarshalParams(v.call.Input, &params); err == nil {
-		args = newParamBuilder().
-			addMain(params.URL).
-			addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
-			addKeyValue("timeout", formatTimeout(params.Timeout)).
-			build()
-	}
-
-	return dr.renderWithParams(v, "Download", args, func() string {
-		return renderPlainContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Glob renderer
-// -----------------------------------------------------------------------------
-
-// globRenderer handles file pattern matching with path filtering
-type globRenderer struct {
-	baseRenderer
-}
-
-// Render displays the glob pattern with optional path parameter
-func (gr globRenderer) Render(v *toolCallCmp) string {
-	var params tools.GlobParams
-	var args []string
-	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
-		args = newParamBuilder().
-			addMain(params.Pattern).
-			addKeyValue("path", params.Path).
-			build()
-	}
-
-	return gr.renderWithParams(v, "Glob", args, func() string {
-		return renderPlainContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Grep renderer
-// -----------------------------------------------------------------------------
-
-// grepRenderer handles content searching with pattern matching options
-type grepRenderer struct {
-	baseRenderer
-}
-
-// Render displays the search pattern with path, include, and literal text options
-func (gr grepRenderer) Render(v *toolCallCmp) string {
-	var params tools.GrepParams
-	var args []string
-	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
-		args = newParamBuilder().
-			addMain(params.Pattern).
-			addKeyValue("path", params.Path).
-			addKeyValue("include", params.Include).
-			addFlag("literal", params.LiteralText).
-			build()
-	}
-
-	return gr.renderWithParams(v, "Grep", args, func() string {
-		return renderPlainContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  LS renderer
-// -----------------------------------------------------------------------------
-
-// lsRenderer handles directory listing with default path handling
-type lsRenderer struct {
-	baseRenderer
-}
-
-// Render displays the directory path, defaulting to current directory
-func (lr lsRenderer) Render(v *toolCallCmp) string {
-	var params tools.LSParams
-	var args []string
-	if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
-		path := params.Path
-		if path == "" {
-			path = "."
-		}
-		path = fsext.PrettyPath(path)
-
-		args = newParamBuilder().addMain(path).build()
-	}
-
-	return lr.renderWithParams(v, "List", args, func() string {
-		return renderPlainContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Sourcegraph renderer
-// -----------------------------------------------------------------------------
-
-// sourcegraphRenderer handles code search with count and context options
-type sourcegraphRenderer struct {
-	baseRenderer
-}
-
-// Render displays the search query with optional count and context window parameters
-func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
-	var params tools.SourcegraphParams
-	var args []string
-	if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
-		args = newParamBuilder().
-			addMain(params.Query).
-			addKeyValue("count", formatNonZero(params.Count)).
-			addKeyValue("context", formatNonZero(params.ContextWindow)).
-			build()
-	}
-
-	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
-		return renderPlainContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Diagnostics renderer
-// -----------------------------------------------------------------------------
-
-// diagnosticsRenderer handles project-wide diagnostic information
-type diagnosticsRenderer struct {
-	baseRenderer
-}
-
-// Render displays project diagnostics with plain content formatting
-func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
-	args := newParamBuilder().addMain("project").build()
-
-	return dr.renderWithParams(v, "Diagnostics", args, func() string {
-		return renderPlainContent(v, v.result.Content)
-	})
-}
-
-// -----------------------------------------------------------------------------
-//  Task renderer
-// -----------------------------------------------------------------------------
-
-// agentRenderer handles project-wide diagnostic information
-type agentRenderer struct {
-	baseRenderer
-}
-
-func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator {
-	if width == 0 {
-		width = 2
-	}
-	if lPadding == 0 {
-		lPadding = 1
-	}
-	return func(children tree.Children, index int) string {
-		line := strings.Repeat("─", width)
-		padding := strings.Repeat(" ", lPadding)
-		if children.Length()-1 == index {
-			return padding + "╰" + line
-		}
-		return padding + "├" + line
-	}
-}
-
-// Render displays agent task parameters and result content
-func (tr agentRenderer) Render(v *toolCallCmp) string {
-	t := styles.CurrentTheme()
-	var params agent.AgentParams
-	tr.unmarshalParams(v.call.Input, &params)
-
-	prompt := params.Prompt
-	prompt = strings.ReplaceAll(prompt, "\n", " ")
-
-	header := tr.makeHeader(v, "Agent", v.textWidth())
-	if res, done := earlyState(header, v); v.cancelled && done {
-		return res
-	}
-	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task")
-	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
-	remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
-	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
-	header = lipgloss.JoinVertical(
-		lipgloss.Left,
-		header,
-		"",
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			taskTag,
-			" ",
-			prompt,
-		),
-	)
-	childTools := tree.Root(header)
-
-	for _, call := range v.nestedToolCalls {
-		call.SetSize(remainingWidth, 1)
-		childTools.Child(call.View())
-	}
-	parts := []string{
-		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
-	}
-
-	if v.result.ToolCallID == "" {
-		v.spinning = true
-		parts = append(parts, "", v.anim.View())
-	} else {
-		v.spinning = false
-	}
-
-	header = lipgloss.JoinVertical(
-		lipgloss.Left,
-		parts...,
-	)
-
-	if v.result.ToolCallID == "" {
-		return header
-	}
-
-	body := renderMarkdownContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
-}
-
-// renderParamList renders params, params[0] (params[1]=params[2] ....)
-func renderParamList(nested bool, paramsWidth int, params ...string) string {
-	t := styles.CurrentTheme()
-	if len(params) == 0 {
-		return ""
-	}
-	mainParam := params[0]
-	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
-		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
-	}
-
-	if len(params) == 1 {
-		return t.S().Subtle.Render(mainParam)
-	}
-	otherParams := params[1:]
-	// create pairs of key/value
-	// if odd number of params, the last one is a key without value
-	if len(otherParams)%2 != 0 {
-		otherParams = append(otherParams, "")
-	}
-	parts := make([]string, 0, len(otherParams)/2)
-	for i := 0; i < len(otherParams); i += 2 {
-		key := otherParams[i]
-		value := otherParams[i+1]
-		if value == "" {
-			continue
-		}
-		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
-	}
-
-	partsRendered := strings.Join(parts, ", ")
-	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
-	if remainingWidth < 30 {
-		// No space for the params, just show the main
-		return t.S().Subtle.Render(mainParam)
-	}
-
-	if len(parts) > 0 {
-		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
-	}
-
-	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
-}
-
-// earlyState returns immediately‑rendered error/cancelled/ongoing states.
-func earlyState(header string, v *toolCallCmp) (string, bool) {
-	t := styles.CurrentTheme()
-	message := ""
-	switch {
-	case v.result.IsError:
-		message = v.renderToolError()
-	case v.cancelled:
-		message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
-	case v.result.ToolCallID == "":
-		if v.permissionRequested && !v.permissionGranted {
-			message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...")
-		} else {
-			message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
-		}
-	default:
-		return "", false
-	}
-
-	message = t.S().Base.PaddingLeft(2).Render(message)
-	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
-}
-
-func joinHeaderBody(header, body string) string {
-	t := styles.CurrentTheme()
-	if body == "" {
-		return header
-	}
-	body = t.S().Base.PaddingLeft(2).Render(body)
-	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
-}
-
-func renderPlainContent(v *toolCallCmp, content string) string {
-	t := styles.CurrentTheme()
-	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
-	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
-	content = strings.TrimSpace(content)
-	lines := strings.Split(content, "\n")
-
-	width := v.textWidth() - 2
-	var out []string
-	for i, ln := range lines {
-		if i >= responseContextHeight {
-			break
-		}
-		ln = ansiext.Escape(ln)
-		ln = " " + ln
-		if lipgloss.Width(ln) > width {
-			ln = v.fit(ln, width)
-		}
-		out = append(out, t.S().Muted.
-			Width(width).
-			Background(t.BgBaseLighter).
-			Render(ln))
-	}
-
-	if len(lines) > responseContextHeight {
-		out = append(out, t.S().Muted.
-			Background(t.BgBaseLighter).
-			Width(width).
-			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
-	}
-
-	return strings.Join(out, "\n")
-}
-
-func renderMarkdownContent(v *toolCallCmp, content string) string {
-	t := styles.CurrentTheme()
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
-
-	width := v.textWidth() - 2
-	width = min(width, 120)
-
-	renderer := styles.GetPlainMarkdownRenderer(width)
-	rendered, err := renderer.Render(content)
-	if err != nil {
-		return renderPlainContent(v, content)
-	}
-
-	lines := strings.Split(rendered, "\n")
-
-	var out []string
-	for i, ln := range lines {
-		if i >= responseContextHeight {
-			break
-		}
-		out = append(out, ln)
-	}
-
-	style := t.S().Muted.Background(t.BgBaseLighter)
-	if len(lines) > responseContextHeight {
-		out = append(out, style.
-			Width(width-2).
-			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
-	}
-
-	return style.Render(strings.Join(out, "\n"))
-}
-
-func getDigits(n int) int {
-	if n == 0 {
-		return 1
-	}
-	if n < 0 {
-		n = -n
-	}
-
-	digits := 0
-	for n > 0 {
-		n /= 10
-		digits++
-	}
-
-	return digits
-}
-
-func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
-	t := styles.CurrentTheme()
-	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
-	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
-	truncated := truncateHeight(content, responseContextHeight)
-
-	lines := strings.Split(truncated, "\n")
-	for i, ln := range lines {
-		lines[i] = ansiext.Escape(ln)
-	}
-
-	bg := t.BgBase
-	highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg)
-	lines = strings.Split(highlighted, "\n")
-
-	if len(strings.Split(content, "\n")) > responseContextHeight {
-		lines = append(lines, t.S().Muted.
-			Background(bg).
-			Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
-	}
-
-	maxLineNumber := len(lines) + offset
-	maxDigits := getDigits(maxLineNumber)
-	numFmt := fmt.Sprintf("%%%dd", maxDigits)
-	const numPR, numPL, codePR, codePL = 1, 1, 1, 2
-	w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding
-	for i, ln := range lines {
-		num := t.S().Base.
-			Foreground(t.FgMuted).
-			Background(t.BgBase).
-			PaddingRight(1).
-			PaddingLeft(1).
-			Render(fmt.Sprintf(numFmt, i+1+offset))
-		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
-			num,
-			t.S().Base.
-				Width(w).
-				Background(bg).
-				PaddingRight(1).
-				PaddingLeft(2).
-				Render(v.fit(ln, w-codePL-codePR)),
-		)
-	}
-
-	return lipgloss.JoinVertical(lipgloss.Left, lines...)
-}
-
-// renderImageContent renders image data with optional text content (for MCP tools).
-func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string {
-	t := styles.CurrentTheme()
-
-	dataSize := len(data) * 3 / 4
-	sizeStr := formatSize(dataSize)
-
-	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
-	arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
-	typeStyled := t.S().Base.Render(mediaType)
-	sizeStyled := t.S().Subtle.Render(sizeStr)
-
-	imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
-	if strings.TrimSpace(textContent) != "" {
-		textDisplay := renderPlainContent(v, textContent)
-		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
-	}
-
-	return imageDisplay
-}
-
-// renderMediaContent renders non-image media content.
-func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string {
-	t := styles.CurrentTheme()
-
-	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
-	arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
-	typeStyled := t.S().Base.Render(mediaType)
-	mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
-
-	if strings.TrimSpace(textContent) != "" {
-		textDisplay := renderPlainContent(v, textContent)
-		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
-	}
-
-	return mediaDisplay
-}
-
-// formatSize formats byte count as human-readable size.
-func formatSize(bytes int) string {
-	if bytes < 1024 {
-		return fmt.Sprintf("%d B", bytes)
-	}
-	if bytes < 1024*1024 {
-		return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
-	}
-	return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
-}
-
-func (v *toolCallCmp) renderToolError() string {
-	t := styles.CurrentTheme()
-	err := strings.ReplaceAll(v.result.Content, "\n", " ")
-	errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
-	err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
-	return err
-}
-
-func truncateHeight(s string, h int) string {
-	lines := strings.Split(s, "\n")
-	if len(lines) > h {
-		return strings.Join(lines[:h], "\n")
-	}
-	return s
-}
-
-func prettifyToolName(name string) string {
-	switch name {
-	case agent.AgentToolName:
-		return "Agent"
-	case tools.BashToolName:
-		return "Bash"
-	case tools.JobOutputToolName:
-		return "Job: Output"
-	case tools.JobKillToolName:
-		return "Job: Kill"
-	case tools.DownloadToolName:
-		return "Download"
-	case tools.EditToolName:
-		return "Edit"
-	case tools.MultiEditToolName:
-		return "Multi-Edit"
-	case tools.FetchToolName:
-		return "Fetch"
-	case tools.AgenticFetchToolName:
-		return "Agentic Fetch"
-	case tools.WebFetchToolName:
-		return "Fetch"
-	case tools.WebSearchToolName:
-		return "Search"
-	case tools.GlobToolName:
-		return "Glob"
-	case tools.GrepToolName:
-		return "Grep"
-	case tools.LSToolName:
-		return "List"
-	case tools.SourcegraphToolName:
-		return "Sourcegraph"
-	case tools.TodosToolName:
-		return "To-Do"
-	case tools.ViewToolName:
-		return "View"
-	case tools.WriteToolName:
-		return "Write"
-	default:
-		return name
-	}
-}
-
-// -----------------------------------------------------------------------------
-//  Todos renderer
-// -----------------------------------------------------------------------------
-
-type todosRenderer struct {
-	baseRenderer
-}
-
-func (tr todosRenderer) Render(v *toolCallCmp) string {
-	t := styles.CurrentTheme()
-	var params tools.TodosParams
-	var meta tools.TodosResponseMetadata
-	var headerText string
-	var body string
-
-	// Parse params for pending state (before result is available).
-	if err := tr.unmarshalParams(v.call.Input, &params); err == nil {
-		completedCount := 0
-		inProgressTask := ""
-		for _, todo := range params.Todos {
-			if todo.Status == "completed" {
-				completedCount++
-			}
-			if todo.Status == "in_progress" {
-				if todo.ActiveForm != "" {
-					inProgressTask = todo.ActiveForm
-				} else {
-					inProgressTask = todo.Content
-				}
-			}
-		}
-
-		// Default display from params (used when pending or no metadata).
-		ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
-		headerText = ratio
-		if inProgressTask != "" {
-			headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
-		}
-
-		// If we have metadata, use it for richer display.
-		if v.result.Metadata != "" {
-			if err := tr.unmarshalParams(v.result.Metadata, &meta); err == nil {
-				if meta.IsNew {
-					if meta.JustStarted != "" {
-						headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
-					} else {
-						headerText = fmt.Sprintf("created %d todos", meta.Total)
-					}
-					body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
-				} else {
-					// Build header based on what changed.
-					hasCompleted := len(meta.JustCompleted) > 0
-					hasStarted := meta.JustStarted != ""
-					allCompleted := meta.Completed == meta.Total
-
-					ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
-					if hasCompleted && hasStarted {
-						text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
-						headerText = fmt.Sprintf("%s%s", ratio, text)
-					} else if hasCompleted {
-						text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
-						if allCompleted {
-							text = t.S().Subtle.Render(" · completed all")
-						}
-						headerText = fmt.Sprintf("%s%s", ratio, text)
-					} else if hasStarted {
-						headerText = fmt.Sprintf("%s%s", ratio, t.S().Subtle.Render(" · starting task"))
-					} else {
-						headerText = ratio
-					}
-
-					// Build body with details.
-					if allCompleted {
-						// Show all todos when all are completed, like when created
-						body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
-					} else if meta.JustStarted != "" {
-						body = t.S().Base.Foreground(t.GreenDark).Render(styles.ArrowRightIcon+" ") +
-							t.S().Base.Foreground(t.FgBase).Render(meta.JustStarted)
-					}
-				}
-			}
-		}
-	}
-
-	args := newParamBuilder().addMain(headerText).build()
-
-	return tr.renderWithParams(v, "To-Do", args, func() string {
-		return body
-	})
-}

internal/tui/components/chat/messages/tool.go 🔗

@@ -1,877 +0,0 @@
-package messages
-
-import (
-	"encoding/json"
-	"fmt"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/atotto/clipboard"
-	"github.com/charmbracelet/crush/internal/agent"
-	"github.com/charmbracelet/crush/internal/agent/tools"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/tui/components/anim"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/x/ansi"
-)
-
-// ToolCallCmp defines the interface for tool call components in the chat interface.
-// It manages the display of tool execution including pending states, results, and errors.
-type ToolCallCmp interface {
-	util.Model                         // Basic Bubble util.Model interface
-	layout.Sizeable                    // Width/height management
-	layout.Focusable                   // Focus state management
-	GetToolCall() message.ToolCall     // Access to tool call data
-	GetToolResult() message.ToolResult // Access to tool result data
-	SetToolResult(message.ToolResult)  // Update tool result
-	SetToolCall(message.ToolCall)      // Update tool call
-	SetCancelled()                     // Mark as cancelled
-	ParentMessageID() string           // Get parent message ID
-	Spinning() bool                    // Animation state for pending tools
-	GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
-	SetNestedToolCalls([]ToolCallCmp)  // Set nested tool calls
-	SetIsNested(bool)                  // Set whether this tool call is nested
-	ID() string
-	SetPermissionRequested() // Mark permission request
-	SetPermissionGranted()   // Mark permission granted
-}
-
-// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
-// It handles rendering of tool execution states including pending, completed, and error states.
-type toolCallCmp struct {
-	width    int  // Component width for text wrapping
-	focused  bool // Focus state for border styling
-	isNested bool // Whether this tool call is nested within another
-
-	// Tool call data and state
-	parentMessageID     string             // ID of the message that initiated this tool call
-	call                message.ToolCall   // The tool call being executed
-	result              message.ToolResult // The result of the tool execution
-	cancelled           bool               // Whether the tool call was cancelled
-	permissionRequested bool
-	permissionGranted   bool
-
-	// Animation state for pending tool calls
-	spinning bool       // Whether to show loading animation
-	anim     util.Model // Animation component for pending states
-
-	nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
-}
-
-// ToolCallOption provides functional options for configuring tool call components
-type ToolCallOption func(*toolCallCmp)
-
-// WithToolCallCancelled marks the tool call as cancelled
-func WithToolCallCancelled() ToolCallOption {
-	return func(m *toolCallCmp) {
-		m.cancelled = true
-	}
-}
-
-// WithToolCallResult sets the initial tool result
-func WithToolCallResult(result message.ToolResult) ToolCallOption {
-	return func(m *toolCallCmp) {
-		m.result = result
-	}
-}
-
-func WithToolCallNested(isNested bool) ToolCallOption {
-	return func(m *toolCallCmp) {
-		m.isNested = isNested
-	}
-}
-
-func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
-	return func(m *toolCallCmp) {
-		m.nestedToolCalls = calls
-	}
-}
-
-func WithToolPermissionRequested() ToolCallOption {
-	return func(m *toolCallCmp) {
-		m.permissionRequested = true
-	}
-}
-
-func WithToolPermissionGranted() ToolCallOption {
-	return func(m *toolCallCmp) {
-		m.permissionGranted = true
-	}
-}
-
-// NewToolCallCmp creates a new tool call component with the given parent message ID,
-// tool call, and optional configuration
-func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp {
-	m := &toolCallCmp{
-		call:            tc,
-		parentMessageID: parentMessageID,
-	}
-	for _, opt := range opts {
-		opt(m)
-	}
-	t := styles.CurrentTheme()
-	m.anim = anim.New(anim.Settings{
-		Size:        15,
-		Label:       "Working",
-		GradColorA:  t.Primary,
-		GradColorB:  t.Secondary,
-		LabelColor:  t.FgBase,
-		CycleColors: true,
-	})
-	if m.isNested {
-		m.anim = anim.New(anim.Settings{
-			Size:        10,
-			GradColorA:  t.Primary,
-			GradColorB:  t.Secondary,
-			CycleColors: true,
-		})
-	}
-	return m
-}
-
-// Init initializes the tool call component and starts animations if needed.
-// Returns a command to start the animation for pending tool calls.
-func (m *toolCallCmp) Init() tea.Cmd {
-	m.spinning = m.shouldSpin()
-	return m.anim.Init()
-}
-
-// Update handles incoming messages and updates the component state.
-// Manages animation updates for pending tool calls.
-func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case anim.StepMsg:
-		var cmds []tea.Cmd
-		for i, nested := range m.nestedToolCalls {
-			if nested.Spinning() {
-				u, cmd := nested.Update(msg)
-				m.nestedToolCalls[i] = u.(ToolCallCmp)
-				cmds = append(cmds, cmd)
-			}
-		}
-		if m.spinning {
-			u, cmd := m.anim.Update(msg)
-			m.anim = u
-			cmds = append(cmds, cmd)
-		}
-		return m, tea.Batch(cmds...)
-	case tea.KeyPressMsg:
-		if key.Matches(msg, CopyKey) {
-			return m, m.copyTool()
-		}
-	}
-	return m, nil
-}
-
-// View renders the tool call component based on its current state.
-// Shows either a pending animation or the tool-specific rendered result.
-func (m *toolCallCmp) View() string {
-	box := m.style()
-
-	if !m.call.Finished && !m.cancelled {
-		return box.Render(m.renderPending())
-	}
-
-	r := registry.lookup(m.call.Name)
-
-	if m.isNested {
-		return box.Render(r.Render(m))
-	}
-	return box.Render(r.Render(m))
-}
-
-// State management methods
-
-// SetCancelled marks the tool call as cancelled
-func (m *toolCallCmp) SetCancelled() {
-	m.cancelled = true
-}
-
-func (m *toolCallCmp) copyTool() tea.Cmd {
-	content := m.formatToolForCopy()
-	return tea.Sequence(
-		tea.SetClipboard(content),
-		func() tea.Msg {
-			_ = clipboard.WriteAll(content)
-			return nil
-		},
-		util.ReportInfo("Tool content copied to clipboard"),
-	)
-}
-
-func (m *toolCallCmp) formatToolForCopy() string {
-	var parts []string
-
-	toolName := prettifyToolName(m.call.Name)
-	parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
-
-	if m.call.Input != "" {
-		params := m.formatParametersForCopy()
-		if params != "" {
-			parts = append(parts, "### Parameters:")
-			parts = append(parts, params)
-		}
-	}
-
-	if m.result.ToolCallID != "" {
-		if m.result.IsError {
-			parts = append(parts, "### Error:")
-			parts = append(parts, m.result.Content)
-		} else {
-			parts = append(parts, "### Result:")
-			content := m.formatResultForCopy()
-			if content != "" {
-				parts = append(parts, content)
-			}
-		}
-	} else if m.cancelled {
-		parts = append(parts, "### Status:")
-		parts = append(parts, "Cancelled")
-	} else {
-		parts = append(parts, "### Status:")
-		parts = append(parts, "Pending...")
-	}
-
-	return strings.Join(parts, "\n\n")
-}
-
-func (m *toolCallCmp) formatParametersForCopy() string {
-	switch m.call.Name {
-	case tools.BashToolName:
-		var params tools.BashParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			cmd := strings.ReplaceAll(params.Command, "\n", " ")
-			cmd = strings.ReplaceAll(cmd, "\t", "    ")
-			return fmt.Sprintf("**Command:** %s", cmd)
-		}
-	case tools.ViewToolName:
-		var params tools.ViewParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			var parts []string
-			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
-			if params.Limit > 0 {
-				parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
-			}
-			if params.Offset > 0 {
-				parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
-			}
-			return strings.Join(parts, "\n")
-		}
-	case tools.EditToolName:
-		var params tools.EditParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
-		}
-	case tools.MultiEditToolName:
-		var params tools.MultiEditParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			var parts []string
-			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
-			parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
-			return strings.Join(parts, "\n")
-		}
-	case tools.WriteToolName:
-		var params tools.WriteParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
-		}
-	case tools.FetchToolName:
-		var params tools.FetchParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			var parts []string
-			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
-			if params.Format != "" {
-				parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
-			}
-			if params.Timeout > 0 {
-				parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
-			}
-			return strings.Join(parts, "\n")
-		}
-	case tools.AgenticFetchToolName:
-		var params tools.AgenticFetchParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			var parts []string
-			if params.URL != "" {
-				parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
-			}
-			if params.Prompt != "" {
-				parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
-			}
-			return strings.Join(parts, "\n")
-		}
-	case tools.WebFetchToolName:
-		var params tools.WebFetchParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			return fmt.Sprintf("**URL:** %s", params.URL)
-		}
-	case tools.GrepToolName:
-		var params tools.GrepParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			var parts []string
-			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
-			if params.Path != "" {
-				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
-			}
-			if params.Include != "" {
-				parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
-			}
-			if params.LiteralText {
-				parts = append(parts, "**Literal:** true")
-			}
-			return strings.Join(parts, "\n")
-		}
-	case tools.GlobToolName:
-		var params tools.GlobParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			var parts []string
-			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
-			if params.Path != "" {
-				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
-			}
-			return strings.Join(parts, "\n")
-		}
-	case tools.LSToolName:
-		var params tools.LSParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			path := params.Path
-			if path == "" {
-				path = "."
-			}
-			return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
-		}
-	case tools.DownloadToolName:
-		var params tools.DownloadParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			var parts []string
-			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
-			parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
-			if params.Timeout > 0 {
-				parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
-			}
-			return strings.Join(parts, "\n")
-		}
-	case tools.SourcegraphToolName:
-		var params tools.SourcegraphParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			var parts []string
-			parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
-			if params.Count > 0 {
-				parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
-			}
-			if params.ContextWindow > 0 {
-				parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
-			}
-			return strings.Join(parts, "\n")
-		}
-	case tools.DiagnosticsToolName:
-		return "**Project:** diagnostics"
-	case agent.AgentToolName:
-		var params agent.AgentParams
-		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-			return fmt.Sprintf("**Task:**\n%s", params.Prompt)
-		}
-	}
-
-	var params map[string]any
-	if json.Unmarshal([]byte(m.call.Input), &params) == nil {
-		var parts []string
-		for key, value := range params {
-			displayKey := strings.ReplaceAll(key, "_", " ")
-			if len(displayKey) > 0 {
-				displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
-			}
-			parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
-		}
-		return strings.Join(parts, "\n")
-	}
-
-	return ""
-}
-
-func (m *toolCallCmp) formatResultForCopy() string {
-	if m.result.Data != "" {
-		if strings.HasPrefix(m.result.MIMEType, "image/") {
-			return fmt.Sprintf("[Image: %s]", m.result.MIMEType)
-		}
-		return fmt.Sprintf("[Media: %s]", m.result.MIMEType)
-	}
-
-	switch m.call.Name {
-	case tools.BashToolName:
-		return m.formatBashResultForCopy()
-	case tools.ViewToolName:
-		return m.formatViewResultForCopy()
-	case tools.EditToolName:
-		return m.formatEditResultForCopy()
-	case tools.MultiEditToolName:
-		return m.formatMultiEditResultForCopy()
-	case tools.WriteToolName:
-		return m.formatWriteResultForCopy()
-	case tools.FetchToolName:
-		return m.formatFetchResultForCopy()
-	case tools.AgenticFetchToolName:
-		return m.formatAgenticFetchResultForCopy()
-	case tools.WebFetchToolName:
-		return m.formatWebFetchResultForCopy()
-	case agent.AgentToolName:
-		return m.formatAgentResultForCopy()
-	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
-		return fmt.Sprintf("```\n%s\n```", m.result.Content)
-	default:
-		return m.result.Content
-	}
-}
-
-func (m *toolCallCmp) formatBashResultForCopy() string {
-	var meta tools.BashResponseMetadata
-	if m.result.Metadata != "" {
-		json.Unmarshal([]byte(m.result.Metadata), &meta)
-	}
-
-	output := meta.Output
-	if output == "" && m.result.Content != tools.BashNoOutput {
-		output = m.result.Content
-	}
-
-	if output == "" {
-		return ""
-	}
-
-	return fmt.Sprintf("```bash\n%s\n```", output)
-}
-
-func (m *toolCallCmp) formatViewResultForCopy() string {
-	var meta tools.ViewResponseMetadata
-	if m.result.Metadata != "" {
-		json.Unmarshal([]byte(m.result.Metadata), &meta)
-	}
-
-	if meta.Content == "" {
-		return m.result.Content
-	}
-
-	lang := ""
-	if meta.FilePath != "" {
-		ext := strings.ToLower(filepath.Ext(meta.FilePath))
-		switch ext {
-		case ".go":
-			lang = "go"
-		case ".js", ".mjs":
-			lang = "javascript"
-		case ".ts":
-			lang = "typescript"
-		case ".py":
-			lang = "python"
-		case ".rs":
-			lang = "rust"
-		case ".java":
-			lang = "java"
-		case ".c":
-			lang = "c"
-		case ".cpp", ".cc", ".cxx":
-			lang = "cpp"
-		case ".sh", ".bash":
-			lang = "bash"
-		case ".json":
-			lang = "json"
-		case ".yaml", ".yml":
-			lang = "yaml"
-		case ".xml":
-			lang = "xml"
-		case ".html":
-			lang = "html"
-		case ".css":
-			lang = "css"
-		case ".md":
-			lang = "markdown"
-		}
-	}
-
-	var result strings.Builder
-	if lang != "" {
-		result.WriteString(fmt.Sprintf("```%s\n", lang))
-	} else {
-		result.WriteString("```\n")
-	}
-	result.WriteString(meta.Content)
-	result.WriteString("\n```")
-
-	return result.String()
-}
-
-func (m *toolCallCmp) formatEditResultForCopy() string {
-	var meta tools.EditResponseMetadata
-	if m.result.Metadata == "" {
-		return m.result.Content
-	}
-
-	if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
-		return m.result.Content
-	}
-
-	var params tools.EditParams
-	json.Unmarshal([]byte(m.call.Input), &params)
-
-	var result strings.Builder
-
-	if meta.OldContent != "" || meta.NewContent != "" {
-		fileName := params.FilePath
-		if fileName != "" {
-			fileName = fsext.PrettyPath(fileName)
-		}
-		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
-
-		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
-		result.WriteString("```diff\n")
-		result.WriteString(diffContent)
-		result.WriteString("\n```")
-	}
-
-	return result.String()
-}
-
-func (m *toolCallCmp) formatMultiEditResultForCopy() string {
-	var meta tools.MultiEditResponseMetadata
-	if m.result.Metadata == "" {
-		return m.result.Content
-	}
-
-	if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
-		return m.result.Content
-	}
-
-	var params tools.MultiEditParams
-	json.Unmarshal([]byte(m.call.Input), &params)
-
-	var result strings.Builder
-	if meta.OldContent != "" || meta.NewContent != "" {
-		fileName := params.FilePath
-		if fileName != "" {
-			fileName = fsext.PrettyPath(fileName)
-		}
-		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
-
-		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
-		result.WriteString("```diff\n")
-		result.WriteString(diffContent)
-		result.WriteString("\n```")
-	}
-
-	return result.String()
-}
-
-func (m *toolCallCmp) formatWriteResultForCopy() string {
-	var params tools.WriteParams
-	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
-		return m.result.Content
-	}
-
-	lang := ""
-	if params.FilePath != "" {
-		ext := strings.ToLower(filepath.Ext(params.FilePath))
-		switch ext {
-		case ".go":
-			lang = "go"
-		case ".js", ".mjs":
-			lang = "javascript"
-		case ".ts":
-			lang = "typescript"
-		case ".py":
-			lang = "python"
-		case ".rs":
-			lang = "rust"
-		case ".java":
-			lang = "java"
-		case ".c":
-			lang = "c"
-		case ".cpp", ".cc", ".cxx":
-			lang = "cpp"
-		case ".sh", ".bash":
-			lang = "bash"
-		case ".json":
-			lang = "json"
-		case ".yaml", ".yml":
-			lang = "yaml"
-		case ".xml":
-			lang = "xml"
-		case ".html":
-			lang = "html"
-		case ".css":
-			lang = "css"
-		case ".md":
-			lang = "markdown"
-		}
-	}
-
-	var result strings.Builder
-	result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
-	if lang != "" {
-		result.WriteString(fmt.Sprintf("```%s\n", lang))
-	} else {
-		result.WriteString("```\n")
-	}
-	result.WriteString(params.Content)
-	result.WriteString("\n```")
-
-	return result.String()
-}
-
-func (m *toolCallCmp) formatFetchResultForCopy() string {
-	var params tools.FetchParams
-	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
-		return m.result.Content
-	}
-
-	var result strings.Builder
-	if params.URL != "" {
-		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
-	}
-	if params.Format != "" {
-		result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
-	}
-	if params.Timeout > 0 {
-		result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
-	}
-	result.WriteString("\n")
-
-	result.WriteString(m.result.Content)
-
-	return result.String()
-}
-
-func (m *toolCallCmp) formatAgenticFetchResultForCopy() string {
-	var params tools.AgenticFetchParams
-	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
-		return m.result.Content
-	}
-
-	var result strings.Builder
-	if params.URL != "" {
-		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
-	}
-	if params.Prompt != "" {
-		result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
-	}
-
-	result.WriteString("```markdown\n")
-	result.WriteString(m.result.Content)
-	result.WriteString("\n```")
-
-	return result.String()
-}
-
-func (m *toolCallCmp) formatWebFetchResultForCopy() string {
-	var params tools.WebFetchParams
-	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
-		return m.result.Content
-	}
-
-	var result strings.Builder
-	result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
-	result.WriteString("```markdown\n")
-	result.WriteString(m.result.Content)
-	result.WriteString("\n```")
-
-	return result.String()
-}
-
-func (m *toolCallCmp) formatAgentResultForCopy() string {
-	var result strings.Builder
-
-	if len(m.nestedToolCalls) > 0 {
-		result.WriteString("### Nested Tool Calls:\n")
-		for i, nestedCall := range m.nestedToolCalls {
-			nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy()
-			indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n  ")
-			result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent))
-			if i < len(m.nestedToolCalls)-1 {
-				result.WriteString("\n")
-			}
-		}
-
-		if m.result.Content != "" {
-			result.WriteString("\n### Final Result:\n")
-		}
-	}
-
-	if m.result.Content != "" {
-		result.WriteString(fmt.Sprintf("```markdown\n%s\n```", m.result.Content))
-	}
-
-	return result.String()
-}
-
-// SetToolCall updates the tool call data and stops spinning if finished
-func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
-	m.call = call
-	if m.call.Finished {
-		m.spinning = false
-	}
-}
-
-// ParentMessageID returns the ID of the message that initiated this tool call
-func (m *toolCallCmp) ParentMessageID() string {
-	return m.parentMessageID
-}
-
-// SetToolResult updates the tool result and stops the spinning animation
-func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
-	m.result = result
-	m.spinning = false
-}
-
-// GetToolCall returns the current tool call data
-func (m *toolCallCmp) GetToolCall() message.ToolCall {
-	return m.call
-}
-
-// GetToolResult returns the current tool result data
-func (m *toolCallCmp) GetToolResult() message.ToolResult {
-	return m.result
-}
-
-// GetNestedToolCalls returns the nested tool calls
-func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
-	return m.nestedToolCalls
-}
-
-// SetNestedToolCalls sets the nested tool calls
-func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
-	m.nestedToolCalls = calls
-	for _, nested := range m.nestedToolCalls {
-		nested.SetSize(m.width, 0)
-	}
-}
-
-// SetIsNested sets whether this tool call is nested within another
-func (m *toolCallCmp) SetIsNested(isNested bool) {
-	m.isNested = isNested
-}
-
-// Rendering methods
-
-// renderPending displays the tool name with a loading animation for pending tool calls
-func (m *toolCallCmp) renderPending() string {
-	t := styles.CurrentTheme()
-	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
-	if m.isNested {
-		tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
-		return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
-	}
-	tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
-	return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
-}
-
-// style returns the lipgloss style for the tool call component.
-// Applies muted colors and focus-dependent border styles.
-func (m *toolCallCmp) style() lipgloss.Style {
-	t := styles.CurrentTheme()
-
-	if m.isNested {
-		return t.S().Muted
-	}
-	style := t.S().Muted.PaddingLeft(2)
-
-	if m.focused {
-		style = style.PaddingLeft(1).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
-	}
-	return style
-}
-
-// textWidth calculates the available width for text content,
-// accounting for borders and padding
-func (m *toolCallCmp) textWidth() int {
-	if m.isNested {
-		return m.width - 6
-	}
-	return m.width - 5 // take into account the border and PaddingLeft
-}
-
-// fit truncates content to fit within the specified width with ellipsis
-func (m *toolCallCmp) fit(content string, width int) string {
-	if lipgloss.Width(content) <= width {
-		return content
-	}
-	t := styles.CurrentTheme()
-	lineStyle := t.S().Muted
-	dots := lineStyle.Render("…")
-	return ansi.Truncate(content, width, dots)
-}
-
-// Focus management methods
-
-// Blur removes focus from the tool call component
-func (m *toolCallCmp) Blur() tea.Cmd {
-	m.focused = false
-	return nil
-}
-
-// Focus sets focus on the tool call component
-func (m *toolCallCmp) Focus() tea.Cmd {
-	m.focused = true
-	return nil
-}
-
-// IsFocused returns whether the tool call component is currently focused
-func (m *toolCallCmp) IsFocused() bool {
-	return m.focused
-}
-
-// Size management methods
-
-// GetSize returns the current dimensions of the tool call component
-func (m *toolCallCmp) GetSize() (int, int) {
-	return m.width, 0
-}
-
-// SetSize updates the width of the tool call component for text wrapping
-func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
-	m.width = width
-	for _, nested := range m.nestedToolCalls {
-		nested.SetSize(width, height)
-	}
-	return nil
-}
-
-// shouldSpin determines whether the tool call should show a loading animation.
-// Returns true if the tool call is not finished or if the result doesn't match the call ID.
-func (m *toolCallCmp) shouldSpin() bool {
-	return !m.call.Finished && !m.cancelled
-}
-
-// Spinning returns whether the tool call is currently showing a loading animation
-func (m *toolCallCmp) Spinning() bool {
-	if m.spinning {
-		return true
-	}
-	for _, nested := range m.nestedToolCalls {
-		if nested.Spinning() {
-			return true
-		}
-	}
-	return m.spinning
-}
-
-func (m *toolCallCmp) ID() string {
-	return m.call.ID
-}
-
-// SetPermissionRequested marks that a permission request was made for this tool call
-func (m *toolCallCmp) SetPermissionRequested() {
-	m.permissionRequested = true
-}
-
-// SetPermissionGranted marks that permission was granted for this tool call
-func (m *toolCallCmp) SetPermissionGranted() {
-	m.permissionGranted = true
-}

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -1,608 +0,0 @@
-package sidebar
-
-import (
-	"context"
-	"fmt"
-	"slices"
-	"strings"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/files"
-	"github.com/charmbracelet/crush/internal/tui/components/logo"
-	lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
-	"github.com/charmbracelet/crush/internal/tui/components/mcp"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/crush/internal/version"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-)
-
-type FileHistory struct {
-	initialVersion history.File
-	latestVersion  history.File
-}
-
-const LogoHeightBreakpoint = 30
-
-// Default maximum number of items to show in each section
-const (
-	DefaultMaxFilesShown = 10
-	DefaultMaxLSPsShown  = 8
-	DefaultMaxMCPsShown  = 8
-	MinItemsPerSection   = 2 // Minimum items to show per section
-)
-
-type SessionFile struct {
-	History   FileHistory
-	FilePath  string
-	Additions int
-	Deletions int
-}
-type SessionFilesMsg struct {
-	Files []SessionFile
-}
-
-type Sidebar interface {
-	util.Model
-	layout.Sizeable
-	SetSession(session session.Session) tea.Cmd
-	SetCompactMode(bool)
-}
-
-type sidebarCmp struct {
-	width, height int
-	session       session.Session
-	logo          string
-	cwd           string
-	lspClients    *csync.Map[string, *lsp.Client]
-	compactMode   bool
-	history       history.Service
-	files         *csync.Map[string, SessionFile]
-}
-
-func New(history history.Service, lspClients *csync.Map[string, *lsp.Client], compact bool) Sidebar {
-	return &sidebarCmp{
-		lspClients:  lspClients,
-		history:     history,
-		compactMode: compact,
-		files:       csync.NewMap[string, SessionFile](),
-	}
-}
-
-func (m *sidebarCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case SessionFilesMsg:
-		m.files = csync.NewMap[string, SessionFile]()
-		for _, file := range msg.Files {
-			m.files.Set(file.FilePath, file)
-		}
-		return m, nil
-
-	case chat.SessionClearedMsg:
-		m.session = session.Session{}
-	case pubsub.Event[history.File]:
-		return m, m.handleFileHistoryEvent(msg)
-	case pubsub.Event[session.Session]:
-		if msg.Type == pubsub.UpdatedEvent {
-			if m.session.ID == msg.Payload.ID {
-				m.session = msg.Payload
-			}
-		}
-	}
-	return m, nil
-}
-
-func (m *sidebarCmp) View() string {
-	t := styles.CurrentTheme()
-	parts := []string{}
-
-	style := t.S().Base.
-		Width(m.width).
-		Height(m.height).
-		Padding(1)
-	if m.compactMode {
-		style = style.PaddingTop(0)
-	}
-
-	if !m.compactMode {
-		if m.height > LogoHeightBreakpoint {
-			parts = append(parts, m.logo)
-		} else {
-			// Use a smaller logo for smaller screens
-			parts = append(parts,
-				logo.SmallRender(m.width-style.GetHorizontalFrameSize()),
-				"")
-		}
-	}
-
-	if !m.compactMode && m.session.ID != "" {
-		parts = append(parts, t.S().Muted.Render(m.session.Title), "")
-	} else if m.session.ID != "" {
-		parts = append(parts, t.S().Text.Render(m.session.Title), "")
-	}
-
-	if !m.compactMode {
-		parts = append(parts,
-			m.cwd,
-			"",
-		)
-	}
-	parts = append(parts,
-		m.currentModelBlock(),
-	)
-
-	// Check if we should use horizontal layout for sections
-	if m.compactMode && m.width > m.height {
-		// Horizontal layout for compact mode when width > height
-		sectionsContent := m.renderSectionsHorizontal()
-		if sectionsContent != "" {
-			parts = append(parts, "", sectionsContent)
-		}
-	} else {
-		// Vertical layout (default)
-		if m.session.ID != "" {
-			parts = append(parts, "", m.filesBlock())
-		}
-		parts = append(parts,
-			"",
-			m.lspBlock(),
-			"",
-			m.mcpBlock(),
-		)
-	}
-
-	return style.Render(
-		lipgloss.JoinVertical(lipgloss.Left, parts...),
-	)
-}
-
-func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
-	return func() tea.Msg {
-		file := event.Payload
-		found := false
-		for existing := range m.files.Seq() {
-			if existing.FilePath != file.Path {
-				continue
-			}
-			if existing.History.latestVersion.Version < file.Version {
-				existing.History.latestVersion = file
-			} else if file.Version == 0 {
-				existing.History.initialVersion = file
-			} else {
-				// If the version is not greater than the latest, we ignore it
-				continue
-			}
-			before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content)
-			after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content)
-			path := existing.History.initialVersion.Path
-			cwd := config.Get().WorkingDir()
-			path = strings.TrimPrefix(path, cwd)
-			_, additions, deletions := diff.GenerateDiff(before, after, path)
-			existing.Additions = additions
-			existing.Deletions = deletions
-			m.files.Set(file.Path, existing)
-			found = true
-			break
-		}
-		if found {
-			return nil
-		}
-		sf := SessionFile{
-			History: FileHistory{
-				initialVersion: file,
-				latestVersion:  file,
-			},
-			FilePath:  file.Path,
-			Additions: 0,
-			Deletions: 0,
-		}
-		m.files.Set(file.Path, sf)
-		return nil
-	}
-}
-
-func (m *sidebarCmp) loadSessionFiles() tea.Msg {
-	files, err := m.history.ListBySession(context.Background(), m.session.ID)
-	if err != nil {
-		return util.InfoMsg{
-			Type: util.InfoTypeError,
-			Msg:  err.Error(),
-		}
-	}
-
-	fileMap := make(map[string]FileHistory)
-
-	for _, file := range files {
-		if existing, ok := fileMap[file.Path]; ok {
-			// Update the latest version
-			existing.latestVersion = file
-			fileMap[file.Path] = existing
-		} else {
-			// Add the initial version
-			fileMap[file.Path] = FileHistory{
-				initialVersion: file,
-				latestVersion:  file,
-			}
-		}
-	}
-
-	sessionFiles := make([]SessionFile, 0, len(fileMap))
-	for path, fh := range fileMap {
-		cwd := config.Get().WorkingDir()
-		path = strings.TrimPrefix(path, cwd)
-		before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content)
-		after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content)
-		_, additions, deletions := diff.GenerateDiff(before, after, path)
-		sessionFiles = append(sessionFiles, SessionFile{
-			History:   fh,
-			FilePath:  path,
-			Additions: additions,
-			Deletions: deletions,
-		})
-	}
-
-	return SessionFilesMsg{
-		Files: sessionFiles,
-	}
-}
-
-func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
-	m.logo = m.logoBlock()
-	m.cwd = cwd()
-	m.width = width
-	m.height = height
-	return nil
-}
-
-func (m *sidebarCmp) GetSize() (int, int) {
-	return m.width, m.height
-}
-
-func (m *sidebarCmp) logoBlock() string {
-	t := styles.CurrentTheme()
-	return logo.Render(version.Version, true, logo.Opts{
-		FieldColor:   t.Primary,
-		TitleColorA:  t.Secondary,
-		TitleColorB:  t.Primary,
-		CharmColor:   t.Secondary,
-		VersionColor: t.Primary,
-		Width:        m.width - 2,
-	})
-}
-
-func (m *sidebarCmp) getMaxWidth() int {
-	return min(m.width-2, 58) // -2 for padding
-}
-
-// calculateAvailableHeight estimates how much height is available for dynamic content
-func (m *sidebarCmp) calculateAvailableHeight() int {
-	usedHeight := 0
-
-	if !m.compactMode {
-		if m.height > LogoHeightBreakpoint {
-			usedHeight += 7 // Approximate logo height
-		} else {
-			usedHeight += 2 // Smaller logo height
-		}
-		usedHeight += 1 // Empty line after logo
-	}
-
-	if m.session.ID != "" {
-		usedHeight += 1 // Title line
-		usedHeight += 1 // Empty line after title
-	}
-
-	if !m.compactMode {
-		usedHeight += 1 // CWD line
-		usedHeight += 1 // Empty line after CWD
-	}
-
-	usedHeight += 2 // Model info
-
-	usedHeight += 6 // 3 sections × 2 lines each (header + empty line)
-
-	// Base padding
-	usedHeight += 2 // Top and bottom padding
-
-	return max(0, m.height-usedHeight)
-}
-
-// getDynamicLimits calculates how many items to show in each section based on available height
-func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
-	availableHeight := m.calculateAvailableHeight()
-
-	// If we have very little space, use minimum values
-	if availableHeight < 10 {
-		return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
-	}
-
-	// Distribute available height among the three sections
-	// Give priority to files, then LSPs, then MCPs
-	totalSections := 3
-	heightPerSection := availableHeight / totalSections
-
-	// Calculate limits for each section, ensuring minimums
-	maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
-	maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
-	maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
-
-	// If we have extra space, give it to files first
-	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
-	if remainingHeight > 0 {
-		extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
-		maxFiles += extraForFiles
-		remainingHeight -= extraForFiles
-
-		if remainingHeight > 0 {
-			extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
-			maxLSPs += extraForLSPs
-			remainingHeight -= extraForLSPs
-
-			if remainingHeight > 0 {
-				maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
-			}
-		}
-	}
-
-	return maxFiles, maxLSPs, maxMCPs
-}
-
-// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
-func (m *sidebarCmp) renderSectionsHorizontal() string {
-	// Calculate available width for each section
-	totalWidth := m.width - 4 // Account for padding and spacing
-	sectionWidth := min(50, totalWidth/3)
-
-	// Get the sections content with limited height
-	var filesContent, lspContent, mcpContent string
-
-	filesContent = m.filesBlockCompact(sectionWidth)
-	lspContent = m.lspBlockCompact(sectionWidth)
-	mcpContent = m.mcpBlockCompact(sectionWidth)
-
-	return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
-}
-
-// filesBlockCompact renders the files block with limited width and height for horizontal layout
-func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
-	// Convert map to slice and handle type conversion
-	sessionFiles := slices.Collect(m.files.Seq())
-	fileSlice := make([]files.SessionFile, len(sessionFiles))
-	for i, sf := range sessionFiles {
-		fileSlice[i] = files.SessionFile{
-			History: files.FileHistory{
-				InitialVersion: sf.History.initialVersion,
-				LatestVersion:  sf.History.latestVersion,
-			},
-			FilePath:  sf.FilePath,
-			Additions: sf.Additions,
-			Deletions: sf.Deletions,
-		}
-	}
-
-	// Limit items for horizontal layout
-	maxItems := min(5, len(fileSlice))
-	availableHeight := m.height - 8 // Reserve space for header and other content
-	if availableHeight > 0 {
-		maxItems = min(maxItems, availableHeight)
-	}
-
-	return files.RenderFileBlock(fileSlice, files.RenderOptions{
-		MaxWidth:    maxWidth,
-		MaxItems:    maxItems,
-		ShowSection: true,
-		SectionName: "Modified Files",
-	}, true)
-}
-
-// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
-func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
-	// Limit items for horizontal layout
-	lspConfigs := config.Get().LSP.Sorted()
-	maxItems := min(5, len(lspConfigs))
-	availableHeight := m.height - 8
-	if availableHeight > 0 {
-		maxItems = min(maxItems, availableHeight)
-	}
-
-	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
-		MaxWidth:    maxWidth,
-		MaxItems:    maxItems,
-		ShowSection: true,
-		SectionName: "LSPs",
-	}, true)
-}
-
-// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
-func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
-	// Limit items for horizontal layout
-	maxItems := min(5, len(config.Get().MCP.Sorted()))
-	availableHeight := m.height - 8
-	if availableHeight > 0 {
-		maxItems = min(maxItems, availableHeight)
-	}
-
-	return mcp.RenderMCPBlock(mcp.RenderOptions{
-		MaxWidth:    maxWidth,
-		MaxItems:    maxItems,
-		ShowSection: true,
-		SectionName: "MCPs",
-	}, true)
-}
-
-func (m *sidebarCmp) filesBlock() string {
-	// Convert map to slice and handle type conversion
-	sessionFiles := slices.Collect(m.files.Seq())
-	fileSlice := make([]files.SessionFile, len(sessionFiles))
-	for i, sf := range sessionFiles {
-		fileSlice[i] = files.SessionFile{
-			History: files.FileHistory{
-				InitialVersion: sf.History.initialVersion,
-				LatestVersion:  sf.History.latestVersion,
-			},
-			FilePath:  sf.FilePath,
-			Additions: sf.Additions,
-			Deletions: sf.Deletions,
-		}
-	}
-
-	// Limit the number of files shown
-	maxFiles, _, _ := m.getDynamicLimits()
-	maxFiles = min(len(fileSlice), maxFiles)
-
-	return files.RenderFileBlock(fileSlice, files.RenderOptions{
-		MaxWidth:    m.getMaxWidth(),
-		MaxItems:    maxFiles,
-		ShowSection: true,
-		SectionName: core.Section("Modified Files", m.getMaxWidth()),
-	}, true)
-}
-
-func (m *sidebarCmp) lspBlock() string {
-	// Limit the number of LSPs shown
-	_, maxLSPs, _ := m.getDynamicLimits()
-
-	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
-		MaxWidth:    m.getMaxWidth(),
-		MaxItems:    maxLSPs,
-		ShowSection: true,
-		SectionName: core.Section("LSPs", m.getMaxWidth()),
-	}, true)
-}
-
-func (m *sidebarCmp) mcpBlock() string {
-	// Limit the number of MCPs shown
-	_, _, maxMCPs := m.getDynamicLimits()
-	mcps := config.Get().MCP.Sorted()
-	maxMCPs = min(len(mcps), maxMCPs)
-
-	return mcp.RenderMCPBlock(mcp.RenderOptions{
-		MaxWidth:    m.getMaxWidth(),
-		MaxItems:    maxMCPs,
-		ShowSection: true,
-		SectionName: core.Section("MCPs", m.getMaxWidth()),
-	}, true)
-}
-
-func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
-	t := styles.CurrentTheme()
-	// Format tokens in human-readable format (e.g., 110K, 1.2M)
-	var formattedTokens string
-	switch {
-	case tokens >= 1_000_000:
-		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
-	case tokens >= 1_000:
-		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
-	default:
-		formattedTokens = fmt.Sprintf("%d", tokens)
-	}
-
-	// Remove .0 suffix if present
-	if strings.HasSuffix(formattedTokens, ".0K") {
-		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
-	}
-	if strings.HasSuffix(formattedTokens, ".0M") {
-		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
-	}
-
-	percentage := (float64(tokens) / float64(contextWindow)) * 100
-
-	baseStyle := t.S().Base
-
-	formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
-
-	formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
-	formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
-	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
-	if percentage > 80 {
-		// add the warning icon
-		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
-	}
-
-	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
-}
-
-func (s *sidebarCmp) currentModelBlock() string {
-	cfg := config.Get()
-	agentCfg := cfg.Agents[config.AgentCoder]
-
-	selectedModel := cfg.Models[agentCfg.Model]
-
-	model := config.Get().GetModelByType(agentCfg.Model)
-
-	t := styles.CurrentTheme()
-
-	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
-	modelName := t.S().Text.Render(model.Name)
-	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
-	parts := []string{
-		modelInfo,
-	}
-	if model.CanReason {
-		reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
-		if len(model.ReasoningLevels) == 0 {
-			formatter := cases.Title(language.English, cases.NoLower)
-			if selectedModel.Think {
-				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
-			} else {
-				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
-			}
-		} else {
-			reasoningEffort := model.DefaultReasoningEffort
-			if selectedModel.ReasoningEffort != "" {
-				reasoningEffort = selectedModel.ReasoningEffort
-			}
-			formatter := cases.Title(language.English, cases.NoLower)
-			parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
-		}
-	}
-	if s.session.ID != "" {
-		parts = append(
-			parts,
-			"  "+formatTokensAndCost(
-				s.session.CompletionTokens+s.session.PromptTokens,
-				model.ContextWindow,
-				s.session.Cost,
-			),
-		)
-	}
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		parts...,
-	)
-}
-
-// SetSession implements Sidebar.
-func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
-	m.session = session
-	return m.loadSessionFiles
-}
-
-// SetCompactMode sets the compact mode for the sidebar.
-func (m *sidebarCmp) SetCompactMode(compact bool) {
-	m.compactMode = compact
-}
-
-func cwd() string {
-	cwd := config.Get().WorkingDir()
-	t := styles.CurrentTheme()
-	return t.S().Muted.Render(home.Short(cwd))
-}

internal/tui/components/chat/splash/keys.go 🔗

@@ -1,58 +0,0 @@
-package splash
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	Select,
-	Next,
-	Previous,
-	Yes,
-	No,
-	Tab,
-	LeftRight,
-	Back,
-	Copy key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Select: key.NewBinding(
-			key.WithKeys("enter", "ctrl+y"),
-			key.WithHelp("enter", "confirm"),
-		),
-		Next: key.NewBinding(
-			key.WithKeys("down", "ctrl+n"),
-			key.WithHelp("↓", "next item"),
-		),
-		Previous: key.NewBinding(
-			key.WithKeys("up", "ctrl+p"),
-			key.WithHelp("↑", "previous item"),
-		),
-		Yes: key.NewBinding(
-			key.WithKeys("y", "Y"),
-			key.WithHelp("y", "yes"),
-		),
-		No: key.NewBinding(
-			key.WithKeys("n", "N"),
-			key.WithHelp("n", "no"),
-		),
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", "switch"),
-		),
-		LeftRight: key.NewBinding(
-			key.WithKeys("left", "right"),
-			key.WithHelp("←/→", "switch"),
-		),
-		Back: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "back"),
-		),
-		Copy: key.NewBinding(
-			key.WithKeys("c"),
-			key.WithHelp("c", "copy url"),
-		),
-	}
-}

internal/tui/components/chat/splash/splash.go 🔗

@@ -1,874 +0,0 @@
-package splash
-
-import (
-	"fmt"
-	"strings"
-	"time"
-
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/spinner"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/catwalk/pkg/catwalk"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/agent"
-	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
-	"github.com/charmbracelet/crush/internal/tui/components/logo"
-	lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
-	"github.com/charmbracelet/crush/internal/tui/components/mcp"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/crush/internal/version"
-)
-
-type Splash interface {
-	util.Model
-	layout.Sizeable
-	layout.Help
-	Cursor() *tea.Cursor
-	// SetOnboarding controls whether the splash shows model selection UI
-	SetOnboarding(bool)
-	// SetProjectInit controls whether the splash shows project initialization prompt
-	SetProjectInit(bool)
-
-	// Showing API key input
-	IsShowingAPIKey() bool
-
-	// IsAPIKeyValid returns whether the API key is valid
-	IsAPIKeyValid() bool
-
-	// IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
-	IsShowingHyperOAuth2() bool
-
-	// IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow
-	IsShowingCopilotOAuth2() bool
-}
-
-const (
-	SplashScreenPaddingY = 1 // Padding Y for the splash screen
-
-	LogoGap = 6
-)
-
-// OnboardingCompleteMsg is sent when onboarding is complete
-type (
-	OnboardingCompleteMsg struct{}
-	SubmitAPIKeyMsg       struct{}
-)
-
-type splashCmp struct {
-	width, height int
-	keyMap        KeyMap
-	logoRendered  string
-
-	// State
-	isOnboarding     bool
-	needsProjectInit bool
-	needsAPIKey      bool
-	selectedNo       bool
-
-	listHeight    int
-	modelList     *models.ModelListComponent
-	apiKeyInput   *models.APIKeyInput
-	selectedModel *models.ModelOption
-	isAPIKeyValid bool
-	apiKeyValue   string
-
-	// Hyper device flow state
-	hyperDeviceFlow     *hyper.DeviceFlow
-	showHyperDeviceFlow bool
-
-	// Copilot device flow state
-	copilotDeviceFlow     *copilot.DeviceFlow
-	showCopilotDeviceFlow bool
-}
-
-func New() Splash {
-	keyMap := DefaultKeyMap()
-	listKeyMap := list.DefaultKeyMap()
-	listKeyMap.Down.SetEnabled(false)
-	listKeyMap.Up.SetEnabled(false)
-	listKeyMap.HalfPageDown.SetEnabled(false)
-	listKeyMap.HalfPageUp.SetEnabled(false)
-	listKeyMap.Home.SetEnabled(false)
-	listKeyMap.End.SetEnabled(false)
-	listKeyMap.DownOneItem = keyMap.Next
-	listKeyMap.UpOneItem = keyMap.Previous
-
-	modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
-	apiKeyInput := models.NewAPIKeyInput()
-
-	return &splashCmp{
-		width:        0,
-		height:       0,
-		keyMap:       keyMap,
-		logoRendered: "",
-		modelList:    modelList,
-		apiKeyInput:  apiKeyInput,
-		selectedNo:   false,
-	}
-}
-
-func (s *splashCmp) SetOnboarding(onboarding bool) {
-	s.isOnboarding = onboarding
-}
-
-func (s *splashCmp) SetProjectInit(needsInit bool) {
-	s.needsProjectInit = needsInit
-}
-
-// GetSize implements SplashPage.
-func (s *splashCmp) GetSize() (int, int) {
-	return s.width, s.height
-}
-
-// Init implements SplashPage.
-func (s *splashCmp) Init() tea.Cmd {
-	return tea.Batch(
-		s.modelList.Init(),
-		s.apiKeyInput.Init(),
-	)
-}
-
-// SetSize implements SplashPage.
-func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
-	wasSmallScreen := s.isSmallScreen()
-	rerenderLogo := width != s.width
-	s.height = height
-	s.width = width
-	if rerenderLogo || wasSmallScreen != s.isSmallScreen() {
-		s.logoRendered = s.logoBlock()
-	}
-	// remove padding, logo height, gap, title space
-	s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
-	listWidth := min(60, width)
-	s.apiKeyInput.SetWidth(width - 2)
-	return s.modelList.SetSize(listWidth, s.listHeight)
-}
-
-// Update implements SplashPage.
-func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		return s, s.SetSize(msg.Width, msg.Height)
-	case hyper.DeviceFlowCompletedMsg:
-		s.showHyperDeviceFlow = false
-		return s, s.saveAPIKeyAndContinue(msg.Token, true)
-	case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
-		if s.hyperDeviceFlow != nil {
-			u, cmd := s.hyperDeviceFlow.Update(msg)
-			s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
-			return s, cmd
-		}
-		return s, nil
-	case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
-		if s.copilotDeviceFlow != nil {
-			u, cmd := s.copilotDeviceFlow.Update(msg)
-			s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
-			return s, cmd
-		}
-		return s, nil
-	case copilot.DeviceFlowCompletedMsg:
-		s.showCopilotDeviceFlow = false
-		return s, s.saveAPIKeyAndContinue(msg.Token, true)
-	case models.APIKeyStateChangeMsg:
-		u, cmd := s.apiKeyInput.Update(msg)
-		s.apiKeyInput = u.(*models.APIKeyInput)
-		if msg.State == models.APIKeyInputStateVerified {
-			return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
-				return SubmitAPIKeyMsg{}
-			})
-		}
-		return s, cmd
-	case SubmitAPIKeyMsg:
-		if s.isAPIKeyValid {
-			return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
-		}
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow:
-			return s, s.hyperDeviceFlow.CopyCode()
-		case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
-			return s, s.copilotDeviceFlow.CopyCode()
-		case key.Matches(msg, s.keyMap.Back):
-			switch {
-			case s.showHyperDeviceFlow:
-				s.hyperDeviceFlow = nil
-				s.showHyperDeviceFlow = false
-				return s, nil
-			case s.showCopilotDeviceFlow:
-				s.copilotDeviceFlow = nil
-				s.showCopilotDeviceFlow = false
-				return s, nil
-			case s.isAPIKeyValid:
-				return s, nil
-			case s.needsAPIKey:
-				s.needsAPIKey = false
-				s.selectedModel = nil
-				s.isAPIKeyValid = false
-				s.apiKeyValue = ""
-				s.apiKeyInput.Reset()
-				return s, nil
-			}
-		case key.Matches(msg, s.keyMap.Select):
-			switch {
-			case s.showHyperDeviceFlow:
-				return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
-			case s.showCopilotDeviceFlow:
-				return s, s.copilotDeviceFlow.CopyCodeAndOpenURL()
-			case s.isAPIKeyValid:
-				return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
-			case s.isOnboarding && !s.needsAPIKey:
-				selectedItem := s.modelList.SelectedModel()
-				if selectedItem == nil {
-					return s, nil
-				}
-				if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
-					cmd := s.setPreferredModel(*selectedItem)
-					s.isOnboarding = false
-					return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
-				} else {
-					switch selectedItem.Provider.ID {
-					case hyperp.Name:
-						s.selectedModel = selectedItem
-						s.showHyperDeviceFlow = true
-						s.hyperDeviceFlow = hyper.NewDeviceFlow()
-						s.hyperDeviceFlow.SetWidth(min(s.width-2, 60))
-						return s, s.hyperDeviceFlow.Init()
-					case catwalk.InferenceProviderCopilot:
-						if token, ok := config.Get().ImportCopilot(); ok {
-							s.selectedModel = selectedItem
-							return s, s.saveAPIKeyAndContinue(token, true)
-						}
-						s.selectedModel = selectedItem
-						s.showCopilotDeviceFlow = true
-						s.copilotDeviceFlow = copilot.NewDeviceFlow()
-						s.copilotDeviceFlow.SetWidth(min(s.width-2, 60))
-						return s, s.copilotDeviceFlow.Init()
-					}
-					// Provider not configured, show API key input
-					s.needsAPIKey = true
-					s.selectedModel = selectedItem
-					s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
-					return s, nil
-				}
-			case s.needsAPIKey:
-				// Handle API key submission
-				s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
-				if s.apiKeyValue == "" {
-					return s, nil
-				}
-
-				provider, err := s.getProvider(s.selectedModel.Provider.ID)
-				if err != nil || provider == nil {
-					return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID))
-				}
-				providerConfig := config.ProviderConfig{
-					ID:      string(s.selectedModel.Provider.ID),
-					Name:    s.selectedModel.Provider.Name,
-					APIKey:  s.apiKeyValue,
-					Type:    provider.Type,
-					BaseURL: provider.APIEndpoint,
-				}
-				return s, tea.Sequence(
-					util.CmdHandler(models.APIKeyStateChangeMsg{
-						State: models.APIKeyInputStateVerifying,
-					}),
-					func() tea.Msg {
-						start := time.Now()
-						err := providerConfig.TestConnection(config.Get().Resolver())
-						// intentionally wait for at least 750ms to make sure the user sees the spinner
-						elapsed := time.Since(start)
-						if elapsed < 750*time.Millisecond {
-							time.Sleep(750*time.Millisecond - elapsed)
-						}
-						if err == nil {
-							s.isAPIKeyValid = true
-							return models.APIKeyStateChangeMsg{
-								State: models.APIKeyInputStateVerified,
-							}
-						}
-						return models.APIKeyStateChangeMsg{
-							State: models.APIKeyInputStateError,
-						}
-					},
-				)
-			case s.needsProjectInit:
-				return s, s.initializeProject()
-			}
-		case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
-			if s.needsAPIKey {
-				u, cmd := s.apiKeyInput.Update(msg)
-				s.apiKeyInput = u.(*models.APIKeyInput)
-				return s, cmd
-			}
-			if s.needsProjectInit {
-				s.selectedNo = !s.selectedNo
-				return s, nil
-			}
-		case key.Matches(msg, s.keyMap.Yes):
-			if s.needsAPIKey {
-				u, cmd := s.apiKeyInput.Update(msg)
-				s.apiKeyInput = u.(*models.APIKeyInput)
-				return s, cmd
-			}
-			if s.isOnboarding {
-				u, cmd := s.modelList.Update(msg)
-				s.modelList = u
-				return s, cmd
-			}
-			if s.needsProjectInit {
-				s.selectedNo = false
-				return s, s.initializeProject()
-			}
-		case key.Matches(msg, s.keyMap.No):
-			if s.needsAPIKey {
-				u, cmd := s.apiKeyInput.Update(msg)
-				s.apiKeyInput = u.(*models.APIKeyInput)
-				return s, cmd
-			}
-			if s.isOnboarding {
-				u, cmd := s.modelList.Update(msg)
-				s.modelList = u
-				return s, cmd
-			}
-			if s.needsProjectInit {
-				s.selectedNo = true
-				return s, s.initializeProject()
-			}
-		default:
-			switch {
-			case s.showHyperDeviceFlow:
-				u, cmd := s.hyperDeviceFlow.Update(msg)
-				s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
-				return s, cmd
-			case s.showCopilotDeviceFlow:
-				u, cmd := s.copilotDeviceFlow.Update(msg)
-				s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
-				return s, cmd
-			case s.needsAPIKey:
-				u, cmd := s.apiKeyInput.Update(msg)
-				s.apiKeyInput = u.(*models.APIKeyInput)
-				return s, cmd
-			case s.isOnboarding:
-				u, cmd := s.modelList.Update(msg)
-				s.modelList = u
-				return s, cmd
-			}
-		}
-	case tea.PasteMsg:
-		switch {
-		case s.showHyperDeviceFlow:
-			u, cmd := s.hyperDeviceFlow.Update(msg)
-			s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
-			return s, cmd
-		case s.showCopilotDeviceFlow:
-			u, cmd := s.copilotDeviceFlow.Update(msg)
-			s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
-			return s, cmd
-		case s.needsAPIKey:
-			u, cmd := s.apiKeyInput.Update(msg)
-			s.apiKeyInput = u.(*models.APIKeyInput)
-			return s, cmd
-		case s.isOnboarding:
-			var cmd tea.Cmd
-			s.modelList, cmd = s.modelList.Update(msg)
-			return s, cmd
-		}
-	case spinner.TickMsg:
-		switch {
-		case s.showHyperDeviceFlow:
-			u, cmd := s.hyperDeviceFlow.Update(msg)
-			s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
-			return s, cmd
-		case s.showCopilotDeviceFlow:
-			u, cmd := s.copilotDeviceFlow.Update(msg)
-			s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
-			return s, cmd
-		default:
-			u, cmd := s.apiKeyInput.Update(msg)
-			s.apiKeyInput = u.(*models.APIKeyInput)
-			return s, cmd
-		}
-	}
-	return s, nil
-}
-
-func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
-	if s.selectedModel == nil {
-		return nil
-	}
-
-	cfg := config.Get()
-	err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
-	if err != nil {
-		return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
-	}
-
-	// Reset API key state and continue with model selection
-	s.needsAPIKey = false
-	cmd := s.setPreferredModel(*s.selectedModel)
-	s.isOnboarding = false
-	s.selectedModel = nil
-	s.isAPIKeyValid = false
-
-	if close {
-		return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
-	}
-	return cmd
-}
-
-func (s *splashCmp) initializeProject() tea.Cmd {
-	s.needsProjectInit = false
-
-	if err := config.MarkProjectInitialized(); err != nil {
-		return util.ReportError(err)
-	}
-	var cmds []tea.Cmd
-
-	cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
-	if !s.selectedNo {
-		initPrompt, err := agent.InitializePrompt(*config.Get())
-		if err != nil {
-			return util.ReportError(err)
-		}
-		cmds = append(cmds,
-			util.CmdHandler(chat.SessionClearedMsg{}),
-			util.CmdHandler(chat.SendMsg{
-				Text: initPrompt,
-			}),
-		)
-	}
-	return tea.Sequence(cmds...)
-}
-
-func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
-	cfg := config.Get()
-	model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
-	if model == nil {
-		return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
-	}
-
-	selectedModel := config.SelectedModel{
-		Model:           selectedItem.Model.ID,
-		Provider:        string(selectedItem.Provider.ID),
-		ReasoningEffort: model.DefaultReasoningEffort,
-		MaxTokens:       model.DefaultMaxTokens,
-	}
-
-	err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
-	if err != nil {
-		return util.ReportError(err)
-	}
-
-	// Now lets automatically setup the small model
-	knownProvider, err := s.getProvider(selectedItem.Provider.ID)
-	if err != nil {
-		return util.ReportError(err)
-	}
-	if knownProvider == nil {
-		// for local provider we just use the same model
-		err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
-		if err != nil {
-			return util.ReportError(err)
-		}
-	} else {
-		smallModel := knownProvider.DefaultSmallModelID
-		model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
-		// should never happen
-		if model == nil {
-			err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
-			if err != nil {
-				return util.ReportError(err)
-			}
-			return nil
-		}
-		smallSelectedModel := config.SelectedModel{
-			Model:           smallModel,
-			Provider:        string(selectedItem.Provider.ID),
-			ReasoningEffort: model.DefaultReasoningEffort,
-			MaxTokens:       model.DefaultMaxTokens,
-		}
-		err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
-		if err != nil {
-			return util.ReportError(err)
-		}
-	}
-	cfg.SetupAgents()
-	return nil
-}
-
-func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
-	cfg := config.Get()
-	providers, err := config.Providers(cfg)
-	if err != nil {
-		return nil, err
-	}
-	for _, p := range providers {
-		if p.ID == providerID {
-			return &p, nil
-		}
-	}
-	return nil, nil
-}
-
-func (s *splashCmp) isProviderConfigured(providerID string) bool {
-	cfg := config.Get()
-	if _, ok := cfg.Providers.Get(providerID); ok {
-		return true
-	}
-	return false
-}
-
-func (s *splashCmp) View() string {
-	t := styles.CurrentTheme()
-	var content string
-
-	switch {
-	case s.showHyperDeviceFlow:
-		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
-		hyperView := s.hyperDeviceFlow.View()
-		hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"),
-				hyperView,
-			),
-		)
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			s.logoRendered,
-			hyperSelector,
-		)
-	case s.showCopilotDeviceFlow:
-		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
-		copilotView := s.copilotDeviceFlow.View()
-		copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"),
-				copilotView,
-			),
-		)
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			s.logoRendered,
-			copilotSelector,
-		)
-	case s.needsAPIKey:
-		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
-		apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
-		apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				apiKeyView,
-			),
-		)
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			s.logoRendered,
-			apiKeySelector,
-		)
-	case s.isOnboarding:
-		modelListView := s.modelList.View()
-		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
-		modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("To start, let’s choose a provider and model."),
-				"",
-				modelListView,
-			),
-		)
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			s.logoRendered,
-			modelSelector,
-		)
-	case s.needsProjectInit:
-		titleStyle := t.S().Base.Foreground(t.FgBase)
-		pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2)
-		bodyStyle := t.S().Base.Foreground(t.FgMuted)
-		shortcutStyle := t.S().Base.Foreground(t.Success)
-
-		initFile := config.Get().Options.InitializeAs
-		initText := lipgloss.JoinVertical(
-			lipgloss.Left,
-			titleStyle.Render("Would you like to initialize this project?"),
-			"",
-			pathStyle.Render(s.cwd()),
-			"",
-			bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
-			bodyStyle.Render(fmt.Sprintf("result into an %s file which serves as general context.", initFile)),
-			"",
-			bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
-			"",
-			bodyStyle.Render("Would you like to initialize now?"),
-		)
-
-		yesButton := core.SelectableButton(core.ButtonOpts{
-			Text:           "Yep!",
-			UnderlineIndex: 0,
-			Selected:       !s.selectedNo,
-		})
-
-		noButton := core.SelectableButton(core.ButtonOpts{
-			Text:           "Nope",
-			UnderlineIndex: 0,
-			Selected:       s.selectedNo,
-		})
-
-		buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, "  ", noButton)
-		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
-
-		initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				initText,
-				"",
-				buttons,
-			),
-		)
-
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			s.logoRendered,
-			"",
-			initContent,
-		)
-	default:
-		parts := []string{
-			s.logoRendered,
-			s.infoSection(),
-		}
-		content = lipgloss.JoinVertical(lipgloss.Left, parts...)
-	}
-
-	return t.S().Base.
-		Width(s.width).
-		Height(s.height).
-		PaddingTop(SplashScreenPaddingY).
-		PaddingBottom(SplashScreenPaddingY).
-		Render(content)
-}
-
-func (s *splashCmp) Cursor() *tea.Cursor {
-	switch {
-	case s.needsAPIKey:
-		cursor := s.apiKeyInput.Cursor()
-		if cursor != nil {
-			return s.moveCursor(cursor)
-		}
-	case s.isOnboarding:
-		cursor := s.modelList.Cursor()
-		if cursor != nil {
-			return s.moveCursor(cursor)
-		}
-	}
-	return nil
-}
-
-func (s *splashCmp) isSmallScreen() bool {
-	// Consider a screen small if either the width is less than 40 or if the
-	// height is less than 20
-	return s.width < 55 || s.height < 20
-}
-
-func (s *splashCmp) infoSection() string {
-	t := styles.CurrentTheme()
-	infoStyle := t.S().Base.PaddingLeft(2)
-	if s.isSmallScreen() {
-		infoStyle = infoStyle.MarginTop(1)
-	}
-	return infoStyle.Render(
-		lipgloss.JoinVertical(
-			lipgloss.Left,
-			s.cwdPart(),
-			"",
-			s.currentModelBlock(),
-			"",
-			lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
-			"",
-		),
-	)
-}
-
-func (s *splashCmp) logoBlock() string {
-	t := styles.CurrentTheme()
-	logoStyle := t.S().Base.Padding(0, 2).Width(s.width)
-	if s.isSmallScreen() {
-		// If the width is too small, render a smaller version of the logo
-		// NOTE: 20 is not correct because [splashCmp.height] is not the
-		// *actual* window height, instead, it is the height of the splash
-		// component and that depends on other variables like compact mode and
-		// the height of the editor.
-		return logoStyle.Render(
-			logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()),
-		)
-	}
-	return logoStyle.Render(
-		logo.Render(version.Version, false, logo.Opts{
-			FieldColor:   t.Primary,
-			TitleColorA:  t.Secondary,
-			TitleColorB:  t.Primary,
-			CharmColor:   t.Secondary,
-			VersionColor: t.Primary,
-			Width:        s.width - logoStyle.GetHorizontalFrameSize(),
-		}),
-	)
-}
-
-func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
-	if cursor == nil {
-		return nil
-	}
-	// Calculate the correct Y offset based on current state
-	logoHeight := lipgloss.Height(s.logoRendered)
-	if s.needsAPIKey {
-		infoSectionHeight := lipgloss.Height(s.infoSection())
-		baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
-		remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
-		offset := baseOffset + remainingHeight
-		cursor.Y += offset
-		cursor.X += 1
-	} else if s.isOnboarding {
-		offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
-		cursor.Y += offset
-		cursor.X += 1
-	}
-
-	return cursor
-}
-
-func (s *splashCmp) logoGap() int {
-	if s.height > 35 {
-		return LogoGap
-	}
-	return 0
-}
-
-// Bindings implements SplashPage.
-func (s *splashCmp) Bindings() []key.Binding {
-	switch {
-	case s.needsAPIKey:
-		return []key.Binding{
-			s.keyMap.Select,
-			s.keyMap.Back,
-		}
-	case s.isOnboarding:
-		return []key.Binding{
-			s.keyMap.Select,
-			s.keyMap.Next,
-			s.keyMap.Previous,
-		}
-	case s.needsProjectInit:
-		return []key.Binding{
-			s.keyMap.Select,
-			s.keyMap.Yes,
-			s.keyMap.No,
-			s.keyMap.Tab,
-			s.keyMap.LeftRight,
-		}
-	default:
-		return []key.Binding{}
-	}
-}
-
-func (s *splashCmp) getMaxInfoWidth() int {
-	return min(s.width-2, 90) // 2 for left padding
-}
-
-func (s *splashCmp) cwdPart() string {
-	t := styles.CurrentTheme()
-	maxWidth := s.getMaxInfoWidth()
-	return t.S().Muted.Width(maxWidth).Render(s.cwd())
-}
-
-func (s *splashCmp) cwd() string {
-	return home.Short(config.Get().WorkingDir())
-}
-
-func LSPList(maxWidth int) []string {
-	return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
-		MaxWidth:    maxWidth,
-		ShowSection: false,
-	})
-}
-
-func (s *splashCmp) lspBlock() string {
-	t := styles.CurrentTheme()
-	maxWidth := s.getMaxInfoWidth() / 2
-	section := t.S().Subtle.Render("LSPs")
-	lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
-	return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
-		lipgloss.JoinVertical(
-			lipgloss.Left,
-			lspList...,
-		),
-	)
-}
-
-func MCPList(maxWidth int) []string {
-	return mcp.RenderMCPList(mcp.RenderOptions{
-		MaxWidth:    maxWidth,
-		ShowSection: false,
-	})
-}
-
-func (s *splashCmp) mcpBlock() string {
-	t := styles.CurrentTheme()
-	maxWidth := s.getMaxInfoWidth() / 2
-	section := t.S().Subtle.Render("MCPs")
-	mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
-	return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
-		lipgloss.JoinVertical(
-			lipgloss.Left,
-			mcpList...,
-		),
-	)
-}
-
-func (s *splashCmp) currentModelBlock() string {
-	cfg := config.Get()
-	agentCfg := cfg.Agents[config.AgentCoder]
-	model := config.Get().GetModelByType(agentCfg.Model)
-	if model == nil {
-		return ""
-	}
-	t := styles.CurrentTheme()
-	modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
-	modelName := t.S().Text.Render(model.Name)
-	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
-	parts := []string{
-		modelInfo,
-	}
-
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		parts...,
-	)
-}
-
-func (s *splashCmp) IsShowingAPIKey() bool {
-	return s.needsAPIKey
-}
-
-func (s *splashCmp) IsAPIKeyValid() bool {
-	return s.isAPIKeyValid
-}
-
-func (s *splashCmp) IsShowingHyperOAuth2() bool {
-	return s.showHyperDeviceFlow
-}
-
-func (s *splashCmp) IsShowingCopilotOAuth2() bool {
-	return s.showCopilotDeviceFlow
-}

internal/tui/components/chat/todos/todos.go 🔗

@@ -1,67 +0,0 @@
-package todos
-
-import (
-	"slices"
-	"strings"
-
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/x/ansi"
-)
-
-func sortTodos(todos []session.Todo) {
-	slices.SortStableFunc(todos, func(a, b session.Todo) int {
-		return statusOrder(a.Status) - statusOrder(b.Status)
-	})
-}
-
-func statusOrder(s session.TodoStatus) int {
-	switch s {
-	case session.TodoStatusCompleted:
-		return 0
-	case session.TodoStatusInProgress:
-		return 1
-	default:
-		return 2
-	}
-}
-
-func FormatTodosList(todos []session.Todo, inProgressIcon string, t *styles.Theme, width int) string {
-	if len(todos) == 0 {
-		return ""
-	}
-
-	sorted := make([]session.Todo, len(todos))
-	copy(sorted, todos)
-	sortTodos(sorted)
-
-	var lines []string
-	for _, todo := range sorted {
-		var prefix string
-		var textStyle lipgloss.Style
-
-		switch todo.Status {
-		case session.TodoStatusCompleted:
-			prefix = t.S().Base.Foreground(t.Green).Render(styles.TodoCompletedIcon) + " "
-			textStyle = t.S().Base.Foreground(t.FgBase)
-		case session.TodoStatusInProgress:
-			prefix = t.S().Base.Foreground(t.GreenDark).Render(inProgressIcon + " ")
-			textStyle = t.S().Base.Foreground(t.FgBase)
-		default:
-			prefix = t.S().Base.Foreground(t.FgMuted).Render(styles.TodoPendingIcon) + " "
-			textStyle = t.S().Base.Foreground(t.FgBase)
-		}
-
-		text := todo.Content
-		if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
-			text = todo.ActiveForm
-		}
-		line := prefix + textStyle.Render(text)
-		line = ansi.Truncate(line, width, "…")
-
-		lines = append(lines, line)
-	}
-
-	return strings.Join(lines, "\n")
-}

internal/tui/components/completions/completions.go 🔗

@@ -1,308 +0,0 @@
-package completions
-
-import (
-	"strings"
-
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const maxCompletionsHeight = 10
-
-type Completion struct {
-	Title string // The title of the completion item
-	Value any    // The value of the completion item
-}
-
-type OpenCompletionsMsg struct {
-	Completions []Completion
-	X           int // X position for the completions popup
-	Y           int // Y position for the completions popup
-	MaxResults  int // Maximum number of results to render, 0 for no limit
-}
-
-type FilterCompletionsMsg struct {
-	Query  string // The query to filter completions
-	Reopen bool
-	X      int // X position for the completions popup
-	Y      int // Y position for the completions popup
-}
-
-type RepositionCompletionsMsg struct {
-	X, Y int
-}
-
-type CompletionsClosedMsg struct{}
-
-type CompletionsOpenedMsg struct{}
-
-type CloseCompletionsMsg struct{}
-
-type SelectCompletionMsg struct {
-	Value  any // The value of the selected completion item
-	Insert bool
-}
-
-type Completions interface {
-	util.Model
-	Open() bool
-	Query() string // Returns the current filter query
-	KeyMap() KeyMap
-	Position() (int, int) // Returns the X and Y position of the completions popup
-	Width() int
-	Height() int
-}
-
-type listModel = list.FilterableList[list.CompletionItem[any]]
-
-type completionsCmp struct {
-	wWidth    int // The window width
-	wHeight   int // The window height
-	width     int
-	lastWidth int
-	height    int  // Height of the completions component`
-	x, xorig  int  // X position for the completions popup
-	y         int  // Y position for the completions popup
-	open      bool // Indicates if the completions are open
-	keyMap    KeyMap
-
-	list  listModel
-	query string // The current filter query
-}
-
-func New() Completions {
-	completionsKeyMap := DefaultKeyMap()
-	keyMap := list.DefaultKeyMap()
-	keyMap.Up.SetEnabled(false)
-	keyMap.Down.SetEnabled(false)
-	keyMap.HalfPageDown.SetEnabled(false)
-	keyMap.HalfPageUp.SetEnabled(false)
-	keyMap.Home.SetEnabled(false)
-	keyMap.End.SetEnabled(false)
-	keyMap.UpOneItem = completionsKeyMap.Up
-	keyMap.DownOneItem = completionsKeyMap.Down
-
-	l := list.NewFilterableList(
-		[]list.CompletionItem[any]{},
-		list.WithFilterInputHidden(),
-		list.WithFilterListOptions(
-			list.WithDirectionBackward(),
-			list.WithKeyMap(keyMap),
-		),
-	)
-	return &completionsCmp{
-		width:  0,
-		height: maxCompletionsHeight,
-		list:   l,
-		query:  "",
-		keyMap: completionsKeyMap,
-	}
-}
-
-// Init implements Completions.
-func (c *completionsCmp) Init() tea.Cmd {
-	return tea.Sequence(
-		c.list.Init(),
-		c.list.SetSize(c.width, c.height),
-	)
-}
-
-// Update implements Completions.
-func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		c.wWidth, c.wHeight = msg.Width, msg.Height
-		return c, nil
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, c.keyMap.Up):
-			u, cmd := c.list.Update(msg)
-			c.list = u.(listModel)
-			return c, cmd
-
-		case key.Matches(msg, c.keyMap.Down):
-			d, cmd := c.list.Update(msg)
-			c.list = d.(listModel)
-			return c, cmd
-		case key.Matches(msg, c.keyMap.UpInsert):
-			s := c.list.SelectedItem()
-			if s == nil {
-				return c, nil
-			}
-			selectedItem := *s
-			c.list.SetSelected(selectedItem.ID())
-			return c, util.CmdHandler(SelectCompletionMsg{
-				Value:  selectedItem.Value(),
-				Insert: true,
-			})
-		case key.Matches(msg, c.keyMap.DownInsert):
-			s := c.list.SelectedItem()
-			if s == nil {
-				return c, nil
-			}
-			selectedItem := *s
-			c.list.SetSelected(selectedItem.ID())
-			return c, util.CmdHandler(SelectCompletionMsg{
-				Value:  selectedItem.Value(),
-				Insert: true,
-			})
-		case key.Matches(msg, c.keyMap.Select):
-			s := c.list.SelectedItem()
-			if s == nil {
-				return c, nil
-			}
-			selectedItem := *s
-			c.open = false // Close completions after selection
-			return c, util.CmdHandler(SelectCompletionMsg{
-				Value: selectedItem.Value(),
-			})
-		case key.Matches(msg, c.keyMap.Cancel):
-			return c, util.CmdHandler(CloseCompletionsMsg{})
-		}
-	case RepositionCompletionsMsg:
-		c.x, c.y = msg.X, msg.Y
-		c.adjustPosition()
-	case CloseCompletionsMsg:
-		c.open = false
-		return c, util.CmdHandler(CompletionsClosedMsg{})
-	case OpenCompletionsMsg:
-		c.open = true
-		c.query = ""
-		c.x, c.xorig = msg.X, msg.X
-		c.y = msg.Y
-		items := []list.CompletionItem[any]{}
-		t := styles.CurrentTheme()
-		for _, completion := range msg.Completions {
-			item := list.NewCompletionItem(
-				completion.Title,
-				completion.Value,
-				list.WithCompletionBackgroundColor(t.BgSubtle),
-			)
-			items = append(items, item)
-		}
-		width := listWidth(items)
-		if len(items) == 0 {
-			width = listWidth(c.list.Items())
-		}
-		if c.x+width >= c.wWidth {
-			c.x = c.wWidth - width - 1
-		}
-		c.width = width
-		c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
-		c.list.SetResultsSize(msg.MaxResults)
-		return c, tea.Batch(
-			c.list.SetItems(items),
-			c.list.SetSize(c.width, c.height),
-			util.CmdHandler(CompletionsOpenedMsg{}),
-		)
-	case FilterCompletionsMsg:
-		if !c.open && !msg.Reopen {
-			return c, nil
-		}
-		if msg.Query == c.query {
-			// PERF: if same query, don't need to filter again
-			return c, nil
-		}
-		if len(c.list.Items()) == 0 &&
-			len(msg.Query) > len(c.query) &&
-			strings.HasPrefix(msg.Query, c.query) {
-			// PERF: if c.query didn't match anything,
-			// AND msg.Query is longer than c.query,
-			// AND msg.Query is prefixed with c.query - which means
-			//		that the user typed more chars after a 0 match,
-			// it won't match anything, so return earlier.
-			return c, nil
-		}
-		c.query = msg.Query
-		var cmds []tea.Cmd
-		cmds = append(cmds, c.list.Filter(msg.Query))
-		items := c.list.Items()
-		itemsLen := len(items)
-		c.xorig = msg.X
-		c.x, c.y = msg.X, msg.Y
-		c.adjustPosition()
-		cmds = append(cmds, c.list.SetSize(c.width, c.height))
-		if itemsLen == 0 {
-			cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
-		} else if msg.Reopen {
-			c.open = true
-			cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
-		}
-		return c, tea.Batch(cmds...)
-	}
-	return c, nil
-}
-
-func (c *completionsCmp) adjustPosition() {
-	items := c.list.Items()
-	itemsLen := len(items)
-	width := listWidth(items)
-	c.lastWidth = c.width
-	if c.x < 0 || width < c.lastWidth {
-		c.x = c.xorig
-	} else if c.x+width >= c.wWidth {
-		c.x = c.wWidth - width - 1
-	}
-	c.width = width
-	c.height = max(min(maxCompletionsHeight, itemsLen), 1)
-}
-
-// View implements Completions.
-func (c *completionsCmp) View() string {
-	if !c.open || len(c.list.Items()) == 0 {
-		return ""
-	}
-
-	t := styles.CurrentTheme()
-	style := t.S().Base.
-		Width(c.width).
-		Height(c.height).
-		Background(t.BgSubtle)
-
-	return style.Render(c.list.View())
-}
-
-// listWidth returns the width of the last 10 items in the list, which is used
-// to determine the width of the completions popup.
-// Note this only works for [completionItemCmp] items.
-func listWidth(items []list.CompletionItem[any]) int {
-	var width int
-	if len(items) == 0 {
-		return width
-	}
-
-	for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
-		itemWidth := lipgloss.Width(items[i].Text()) + 2 // +2 for padding
-		width = max(width, itemWidth)
-	}
-
-	return width
-}
-
-func (c *completionsCmp) Open() bool {
-	return c.open
-}
-
-func (c *completionsCmp) Query() string {
-	return c.query
-}
-
-func (c *completionsCmp) KeyMap() KeyMap {
-	return c.keyMap
-}
-
-func (c *completionsCmp) Position() (int, int) {
-	return c.x, c.y - c.height
-}
-
-func (c *completionsCmp) Width() int {
-	return c.width
-}
-
-func (c *completionsCmp) Height() int {
-	return c.height
-}

internal/tui/components/completions/keys.go 🔗

@@ -1,72 +0,0 @@
-package completions
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	Down,
-	Up,
-	Select,
-	Cancel key.Binding
-	DownInsert,
-	UpInsert key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Down: key.NewBinding(
-			key.WithKeys("down"),
-			key.WithHelp("down", "move down"),
-		),
-		Up: key.NewBinding(
-			key.WithKeys("up"),
-			key.WithHelp("up", "move up"),
-		),
-		Select: key.NewBinding(
-			key.WithKeys("enter", "tab", "ctrl+y"),
-			key.WithHelp("enter", "select"),
-		),
-		Cancel: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-		DownInsert: key.NewBinding(
-			key.WithKeys("ctrl+n"),
-			key.WithHelp("ctrl+n", "insert next"),
-		),
-		UpInsert: key.NewBinding(
-			key.WithKeys("ctrl+p"),
-			key.WithHelp("ctrl+p", "insert previous"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Down,
-		k.Up,
-		k.Select,
-		k.Cancel,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.Up,
-		k.Down,
-	}
-}

internal/tui/components/core/core.go 🔗

@@ -1,207 +0,0 @@
-package core
-
-import (
-	"image/color"
-	"strings"
-
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	"charm.land/lipgloss/v2"
-	"github.com/alecthomas/chroma/v2"
-	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/x/ansi"
-)
-
-type KeyMapHelp interface {
-	Help() help.KeyMap
-}
-
-type simpleHelp struct {
-	shortList []key.Binding
-	fullList  [][]key.Binding
-}
-
-func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap {
-	return &simpleHelp{
-		shortList: shortList,
-		fullList:  fullList,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (s *simpleHelp) FullHelp() [][]key.Binding {
-	return s.fullList
-}
-
-// ShortHelp implements help.KeyMap.
-func (s *simpleHelp) ShortHelp() []key.Binding {
-	return s.shortList
-}
-
-func Section(text string, width int) string {
-	t := styles.CurrentTheme()
-	char := "─"
-	length := lipgloss.Width(text) + 1
-	remainingWidth := width - length
-	lineStyle := t.S().Base.Foreground(t.Border)
-	if remainingWidth > 0 {
-		text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
-	}
-	return text
-}
-
-func SectionWithInfo(text string, width int, info string) string {
-	t := styles.CurrentTheme()
-	char := "─"
-	length := lipgloss.Width(text) + 1
-	remainingWidth := width - length
-
-	if info != "" {
-		remainingWidth -= lipgloss.Width(info) + 1 // 1 for the space before info
-	}
-	lineStyle := t.S().Base.Foreground(t.Border)
-	if remainingWidth > 0 {
-		text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + " " + info
-	}
-	return text
-}
-
-func Title(title string, width int) string {
-	t := styles.CurrentTheme()
-	char := "╱"
-	length := lipgloss.Width(title) + 1
-	remainingWidth := width - length
-	titleStyle := t.S().Base.Foreground(t.Primary)
-	if remainingWidth > 0 {
-		lines := strings.Repeat(char, remainingWidth)
-		lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
-		title = titleStyle.Render(title) + " " + lines
-	}
-	return title
-}
-
-type StatusOpts struct {
-	Icon             string // if empty no icon will be shown
-	Title            string
-	TitleColor       color.Color
-	Description      string
-	DescriptionColor color.Color
-	ExtraContent     string // additional content to append after the description
-}
-
-func Status(opts StatusOpts, width int) string {
-	t := styles.CurrentTheme()
-	icon := opts.Icon
-	title := opts.Title
-	titleColor := t.FgMuted
-	if opts.TitleColor != nil {
-		titleColor = opts.TitleColor
-	}
-	description := opts.Description
-	descriptionColor := t.FgSubtle
-	if opts.DescriptionColor != nil {
-		descriptionColor = opts.DescriptionColor
-	}
-	title = t.S().Base.Foreground(titleColor).Render(title)
-	if description != "" {
-		extraContentWidth := lipgloss.Width(opts.ExtraContent)
-		if extraContentWidth > 0 {
-			extraContentWidth += 1
-		}
-		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
-		description = t.S().Base.Foreground(descriptionColor).Render(description)
-	}
-
-	content := []string{}
-	if icon != "" {
-		content = append(content, icon)
-	}
-	content = append(content, title)
-	if description != "" {
-		content = append(content, description)
-	}
-	if opts.ExtraContent != "" {
-		content = append(content, opts.ExtraContent)
-	}
-
-	return strings.Join(content, " ")
-}
-
-type ButtonOpts struct {
-	Text           string
-	UnderlineIndex int  // Index of character to underline (0-based)
-	Selected       bool // Whether this button is selected
-}
-
-// SelectableButton creates a button with an underlined character and selection state
-func SelectableButton(opts ButtonOpts) string {
-	t := styles.CurrentTheme()
-
-	// Base style for the button
-	buttonStyle := t.S().Text
-
-	// Apply selection styling
-	if opts.Selected {
-		buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary)
-	} else {
-		buttonStyle = buttonStyle.Background(t.BgSubtle)
-	}
-
-	// Create the button text with underlined character
-	text := opts.Text
-	if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) {
-		before := text[:opts.UnderlineIndex]
-		underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1]
-		after := text[opts.UnderlineIndex+1:]
-
-		message := buttonStyle.Render(before) +
-			buttonStyle.Underline(true).Render(underlined) +
-			buttonStyle.Render(after)
-
-		return buttonStyle.Padding(0, 2).Render(message)
-	}
-
-	// Fallback if no underline index specified
-	return buttonStyle.Padding(0, 2).Render(text)
-}
-
-// SelectableButtons creates a horizontal row of selectable buttons
-func SelectableButtons(buttons []ButtonOpts, spacing string) string {
-	if spacing == "" {
-		spacing = "  "
-	}
-
-	var parts []string
-	for i, button := range buttons {
-		parts = append(parts, SelectableButton(button))
-		if i < len(buttons)-1 {
-			parts = append(parts, spacing)
-		}
-	}
-
-	return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
-}
-
-// SelectableButtonsVertical creates a vertical row of selectable buttons
-func SelectableButtonsVertical(buttons []ButtonOpts, spacing int) string {
-	var parts []string
-	for i, button := range buttons {
-		parts = append(parts, SelectableButton(button))
-		if i < len(buttons)-1 {
-			for range spacing {
-				parts = append(parts, "")
-			}
-		}
-	}
-
-	return lipgloss.JoinVertical(lipgloss.Center, parts...)
-}
-
-func DiffFormatter() *diffview.DiffView {
-	t := styles.CurrentTheme()
-	formatDiff := diffview.New()
-	style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
-	diff := formatDiff.ChromaStyle(style).Style(t.S().Diff).TabWidth(4)
-	return diff
-}

internal/tui/components/core/layout/layout.go 🔗

@@ -1,27 +0,0 @@
-package layout
-
-import (
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-)
-
-// TODO: move this to core
-
-type Focusable interface {
-	Focus() tea.Cmd
-	Blur() tea.Cmd
-	IsFocused() bool
-}
-
-type Sizeable interface {
-	SetSize(width, height int) tea.Cmd
-	GetSize() (int, int)
-}
-
-type Help interface {
-	Bindings() []key.Binding
-}
-
-type Positional interface {
-	SetPosition(x, y int) tea.Cmd
-}

internal/tui/components/core/status/status.go 🔗

@@ -1,113 +0,0 @@
-package status
-
-import (
-	"time"
-
-	"charm.land/bubbles/v2/help"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/x/ansi"
-)
-
-type StatusCmp interface {
-	util.Model
-	ToggleFullHelp()
-	SetKeyMap(keyMap help.KeyMap)
-}
-
-type statusCmp struct {
-	info       util.InfoMsg
-	width      int
-	messageTTL time.Duration
-	help       help.Model
-	keyMap     help.KeyMap
-}
-
-// clearMessageCmd is a command that clears status messages after a timeout
-func (m *statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
-	return tea.Tick(ttl, func(time.Time) tea.Msg {
-		return util.ClearStatusMsg{}
-	})
-}
-
-func (m *statusCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.help.SetWidth(msg.Width - 2)
-		return m, nil
-
-	// Handle status info
-	case util.InfoMsg:
-		m.info = msg
-		ttl := msg.TTL
-		if ttl == 0 {
-			ttl = m.messageTTL
-		}
-		return m, m.clearMessageCmd(ttl)
-	case util.ClearStatusMsg:
-		m.info = util.InfoMsg{}
-	}
-	return m, nil
-}
-
-func (m *statusCmp) View() string {
-	t := styles.CurrentTheme()
-	status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
-	if m.info.Msg != "" {
-		status = m.infoMsg()
-	}
-	return status
-}
-
-func (m *statusCmp) infoMsg() string {
-	t := styles.CurrentTheme()
-	message := ""
-	infoType := ""
-	switch m.info.Type {
-	case util.InfoTypeError:
-		infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
-		widthLeft := m.width - (lipgloss.Width(infoType) + 2)
-		info := ansi.Truncate(m.info.Msg, widthLeft, "…")
-		message = t.S().Base.Background(t.Error).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info)
-	case util.InfoTypeWarn:
-		infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
-		widthLeft := m.width - (lipgloss.Width(infoType) + 2)
-		info := ansi.Truncate(m.info.Msg, widthLeft, "…")
-		message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info)
-	default:
-		note := "OKAY!"
-		if m.info.Type == util.InfoTypeUpdate {
-			note = "HEY!"
-		}
-		infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render(note)
-		widthLeft := m.width - (lipgloss.Width(infoType) + 2)
-		info := ansi.Truncate(m.info.Msg, widthLeft, "…")
-		message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info)
-	}
-	return ansi.Truncate(infoType+message, m.width, "…")
-}
-
-func (m *statusCmp) ToggleFullHelp() {
-	m.help.ShowAll = !m.help.ShowAll
-}
-
-func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) {
-	m.keyMap = keyMap
-}
-
-func NewStatusCmp() StatusCmp {
-	t := styles.CurrentTheme()
-	help := help.New()
-	help.Styles = t.S().Help
-	return &statusCmp{
-		messageTTL: 5 * time.Second,
-		help:       help,
-	}
-}

internal/tui/components/core/status_test.go 🔗

@@ -1,144 +0,0 @@
-package core_test
-
-import (
-	"fmt"
-	"image/color"
-	"testing"
-
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/x/exp/golden"
-)
-
-func TestStatus(t *testing.T) {
-	t.Parallel()
-
-	tests := []struct {
-		name  string
-		opts  core.StatusOpts
-		width int
-	}{
-		{
-			name: "Default",
-			opts: core.StatusOpts{
-				Title:       "Status",
-				Description: "Everything is working fine",
-			},
-			width: 80,
-		},
-		{
-			name: "WithCustomIcon",
-			opts: core.StatusOpts{
-				Icon:        "✓",
-				Title:       "Success",
-				Description: "Operation completed successfully",
-			},
-			width: 80,
-		},
-		{
-			name: "NoIcon",
-			opts: core.StatusOpts{
-				Title:       "Info",
-				Description: "This status has no icon",
-			},
-			width: 80,
-		},
-		{
-			name: "WithColors",
-			opts: core.StatusOpts{
-				Icon:             "⚠",
-				Title:            "Warning",
-				TitleColor:       color.RGBA{255, 255, 0, 255}, // Yellow
-				Description:      "This is a warning message",
-				DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red
-			},
-			width: 80,
-		},
-		{
-			name: "WithExtraContent",
-			opts: core.StatusOpts{
-				Title:        "Build",
-				Description:  "Building project",
-				ExtraContent: "[2/5]",
-			},
-			width: 80,
-		},
-		{
-			name: "LongDescription",
-			opts: core.StatusOpts{
-				Title:       "Processing",
-				Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping",
-			},
-			width: 60,
-		},
-		{
-			name: "NarrowWidth",
-			opts: core.StatusOpts{
-				Icon:        "●",
-				Title:       "Status",
-				Description: "Short message",
-			},
-			width: 30,
-		},
-		{
-			name: "VeryNarrowWidth",
-			opts: core.StatusOpts{
-				Icon:        "●",
-				Title:       "Test",
-				Description: "This will be truncated",
-			},
-			width: 20,
-		},
-		{
-			name: "EmptyDescription",
-			opts: core.StatusOpts{
-				Icon:  "●",
-				Title: "Title Only",
-			},
-			width: 80,
-		},
-		{
-			name: "AllFieldsWithExtraContent",
-			opts: core.StatusOpts{
-				Icon:             "🚀",
-				Title:            "Deployment",
-				TitleColor:       color.RGBA{0, 0, 255, 255}, // Blue
-				Description:      "Deploying to production environment",
-				DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray
-				ExtraContent:     "v1.2.3",
-			},
-			width: 80,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-
-			output := core.Status(tt.opts, tt.width)
-			golden.RequireEqual(t, []byte(output))
-		})
-	}
-}
-
-func TestStatusTruncation(t *testing.T) {
-	t.Parallel()
-
-	opts := core.StatusOpts{
-		Icon:         "●",
-		Title:        "Very Long Title",
-		Description:  "This is an extremely long description that definitely needs to be truncated",
-		ExtraContent: "[extra]",
-	}
-
-	// Test different widths to ensure truncation works correctly
-	widths := []int{20, 30, 40, 50, 60}
-
-	for _, width := range widths {
-		t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) {
-			t.Parallel()
-
-			output := core.Status(opts, width)
-			golden.RequireEqual(t, []byte(output))
-		})
-	}
-}

internal/tui/components/dialogs/commands/arguments.go 🔗

@@ -1,245 +0,0 @@
-package commands
-
-import (
-	"cmp"
-
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/textinput"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/crush/internal/uicmd"
-)
-
-const (
-	argumentsDialogID dialogs.DialogID = "arguments"
-)
-
-// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
-type ShowArgumentsDialogMsg = uicmd.ShowArgumentsDialogMsg
-
-// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
-type CloseArgumentsDialogMsg = uicmd.CloseArgumentsDialogMsg
-
-// CommandArgumentsDialog represents the commands dialog.
-type CommandArgumentsDialog interface {
-	dialogs.DialogModel
-}
-
-type commandArgumentsDialogCmp struct {
-	wWidth, wHeight int
-	width, height   int
-
-	inputs    []textinput.Model
-	focused   int
-	keys      ArgumentsDialogKeyMap
-	arguments []Argument
-	help      help.Model
-
-	id          string
-	title       string
-	name        string
-	description string
-
-	onSubmit func(args map[string]string) tea.Cmd
-}
-
-type Argument struct {
-	Name, Title, Description string
-	Required                 bool
-}
-
-func NewCommandArgumentsDialog(
-	id, title, name, description string,
-	arguments []Argument,
-	onSubmit func(args map[string]string) tea.Cmd,
-) CommandArgumentsDialog {
-	t := styles.CurrentTheme()
-	inputs := make([]textinput.Model, len(arguments))
-
-	for i, arg := range arguments {
-		ti := textinput.New()
-		ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Title)
-		ti.SetWidth(40)
-		ti.SetVirtualCursor(false)
-		ti.Prompt = ""
-
-		ti.SetStyles(t.S().TextInput)
-		// Only focus the first input initially
-		if i == 0 {
-			ti.Focus()
-		} else {
-			ti.Blur()
-		}
-
-		inputs[i] = ti
-	}
-
-	return &commandArgumentsDialogCmp{
-		inputs:      inputs,
-		keys:        DefaultArgumentsDialogKeyMap(),
-		id:          id,
-		name:        name,
-		title:       title,
-		description: description,
-		arguments:   arguments,
-		width:       60,
-		help:        help.New(),
-		onSubmit:    onSubmit,
-	}
-}
-
-// Init implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
-	return nil
-}
-
-// Update implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		c.wWidth = msg.Width
-		c.wHeight = msg.Height
-		c.width = min(90, c.wWidth)
-		c.height = min(15, c.wHeight)
-		for i := range c.inputs {
-			c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2))
-		}
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, c.keys.Close):
-			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-		case key.Matches(msg, c.keys.Confirm):
-			if c.focused == len(c.inputs)-1 {
-				args := make(map[string]string)
-				for i, arg := range c.arguments {
-					value := c.inputs[i].Value()
-					args[arg.Name] = value
-				}
-				return c, tea.Sequence(
-					util.CmdHandler(dialogs.CloseDialogMsg{}),
-					c.onSubmit(args),
-				)
-			}
-			// Otherwise, move to the next input
-			c.inputs[c.focused].Blur()
-			c.focused++
-			c.inputs[c.focused].Focus()
-		case key.Matches(msg, c.keys.Next):
-			// Move to the next input
-			c.inputs[c.focused].Blur()
-			c.focused = (c.focused + 1) % len(c.inputs)
-			c.inputs[c.focused].Focus()
-		case key.Matches(msg, c.keys.Previous):
-			// Move to the previous input
-			c.inputs[c.focused].Blur()
-			c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs)
-			c.inputs[c.focused].Focus()
-		case key.Matches(msg, c.keys.Close):
-			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-		default:
-			var cmd tea.Cmd
-			c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
-			return c, cmd
-		}
-	case tea.PasteMsg:
-		var cmd tea.Cmd
-		c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
-		return c, cmd
-	}
-	return c, nil
-}
-
-// View implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) View() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	title := lipgloss.NewStyle().
-		Foreground(t.Primary).
-		Bold(true).
-		Padding(0, 1).
-		Render(cmp.Or(c.title, c.name))
-
-	promptName := t.S().Text.
-		Padding(0, 1).
-		Render(c.description)
-
-	inputFields := make([]string, len(c.inputs))
-	for i, input := range c.inputs {
-		labelStyle := baseStyle.Padding(1, 1, 0, 1)
-
-		if i == c.focused {
-			labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
-		} else {
-			labelStyle = labelStyle.Foreground(t.FgMuted)
-		}
-
-		arg := c.arguments[i]
-		argName := cmp.Or(arg.Title, arg.Name)
-		if arg.Required {
-			argName += "*"
-		}
-		label := labelStyle.Render(argName + ":")
-
-		field := t.S().Text.
-			Padding(0, 1).
-			Render(input.View())
-
-		inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
-	}
-
-	elements := []string{title, promptName}
-	elements = append(elements, inputFields...)
-
-	c.help.ShowAll = false
-	helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
-	elements = append(elements, "", helpText)
-
-	content := lipgloss.JoinVertical(lipgloss.Left, elements...)
-
-	return baseStyle.Padding(1, 1, 0, 1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus).
-		Width(c.width).
-		Render(content)
-}
-
-func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor {
-	if len(c.inputs) == 0 {
-		return nil
-	}
-	cursor := c.inputs[c.focused].Cursor()
-	if cursor != nil {
-		cursor = c.moveCursor(cursor)
-	}
-	return cursor
-}
-
-const (
-	headerHeight      = 3
-	itemHeight        = 3
-	paddingHorizontal = 3
-)
-
-func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
-	row, col := c.Position()
-	offset := row + headerHeight + (1+c.focused)*itemHeight
-	cursor.Y += offset
-	cursor.X = cursor.X + col + paddingHorizontal
-	return cursor
-}
-
-func (c *commandArgumentsDialogCmp) Position() (int, int) {
-	row := (c.wHeight / 2) - (c.height / 2)
-	col := (c.wWidth / 2) - (c.width / 2)
-	return row, col
-}
-
-// ID implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) ID() dialogs.DialogID {
-	return argumentsDialogID
-}

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -1,479 +0,0 @@
-package commands
-
-import (
-	"fmt"
-	"os"
-	"slices"
-	"strings"
-
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-
-	"github.com/charmbracelet/crush/internal/agent"
-	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/crush/internal/uicmd"
-)
-
-const (
-	CommandsDialogID dialogs.DialogID = "commands"
-
-	defaultWidth int = 70
-)
-
-type commandType = uicmd.CommandType
-
-const (
-	SystemCommands = uicmd.SystemCommands
-	UserCommands   = uicmd.UserCommands
-	MCPPrompts     = uicmd.MCPPrompts
-)
-
-type listModel = list.FilterableList[list.CompletionItem[Command]]
-
-// Command represents a command that can be executed
-type (
-	Command                         = uicmd.Command
-	CommandRunCustomMsg             = uicmd.CommandRunCustomMsg
-	ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg
-)
-
-// CommandsDialog represents the commands dialog.
-type CommandsDialog interface {
-	dialogs.DialogModel
-}
-
-type commandDialogCmp struct {
-	width   int
-	wWidth  int // Width of the terminal window
-	wHeight int // Height of the terminal window
-
-	commandList  listModel
-	keyMap       CommandsDialogKeyMap
-	help         help.Model
-	selected     commandType           // Selected SystemCommands, UserCommands, or MCPPrompts
-	userCommands []Command             // User-defined commands
-	mcpPrompts   *csync.Slice[Command] // MCP prompts
-	sessionID    string                // Current session ID
-}
-
-type (
-	SwitchSessionsMsg      struct{}
-	NewSessionsMsg         struct{}
-	SwitchModelMsg         struct{}
-	QuitMsg                struct{}
-	OpenFilePickerMsg      struct{}
-	ToggleHelpMsg          struct{}
-	ToggleCompactModeMsg   struct{}
-	ToggleThinkingMsg      struct{}
-	OpenReasoningDialogMsg struct{}
-	OpenExternalEditorMsg  struct{}
-	ToggleYoloModeMsg      struct{}
-	CompactMsg             struct {
-		SessionID string
-	}
-)
-
-func NewCommandDialog(sessionID string) CommandsDialog {
-	keyMap := DefaultCommandsDialogKeyMap()
-	listKeyMap := list.DefaultKeyMap()
-	listKeyMap.Down.SetEnabled(false)
-	listKeyMap.Up.SetEnabled(false)
-	listKeyMap.DownOneItem = keyMap.Next
-	listKeyMap.UpOneItem = keyMap.Previous
-
-	t := styles.CurrentTheme()
-	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
-	commandList := list.NewFilterableList(
-		[]list.CompletionItem[Command]{},
-		list.WithFilterInputStyle(inputStyle),
-		list.WithFilterListOptions(
-			list.WithKeyMap(listKeyMap),
-			list.WithWrapNavigation(),
-			list.WithResizeByList(),
-		),
-	)
-	help := help.New()
-	help.Styles = t.S().Help
-	return &commandDialogCmp{
-		commandList: commandList,
-		width:       defaultWidth,
-		keyMap:      DefaultCommandsDialogKeyMap(),
-		help:        help,
-		selected:    SystemCommands,
-		sessionID:   sessionID,
-		mcpPrompts:  csync.NewSlice[Command](),
-	}
-}
-
-func (c *commandDialogCmp) Init() tea.Cmd {
-	commands, err := uicmd.LoadCustomCommands()
-	if err != nil {
-		return util.ReportError(err)
-	}
-	c.userCommands = commands
-	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
-	return c.setCommandType(c.selected)
-}
-
-func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		c.wWidth = msg.Width
-		c.wHeight = msg.Height
-		return c, tea.Batch(
-			c.setCommandType(c.selected),
-			c.commandList.SetSize(c.listWidth(), c.listHeight()),
-		)
-	case pubsub.Event[mcp.Event]:
-		// Reload MCP prompts when MCP state changes
-		if msg.Type == pubsub.UpdatedEvent {
-			c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
-			// If we're currently viewing MCP prompts, refresh the list
-			if c.selected == MCPPrompts {
-				return c, c.setCommandType(MCPPrompts)
-			}
-			return c, nil
-		}
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, c.keyMap.Select):
-			selectedItem := c.commandList.SelectedItem()
-			if selectedItem == nil {
-				return c, nil // No item selected, do nothing
-			}
-			command := (*selectedItem).Value()
-			return c, tea.Sequence(
-				util.CmdHandler(dialogs.CloseDialogMsg{}),
-				command.Handler(command),
-			)
-		case key.Matches(msg, c.keyMap.Tab):
-			if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
-				return c, nil
-			}
-			return c, c.setCommandType(c.next())
-		case key.Matches(msg, c.keyMap.Close):
-			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-		default:
-			u, cmd := c.commandList.Update(msg)
-			c.commandList = u.(listModel)
-			return c, cmd
-		}
-	}
-	return c, nil
-}
-
-func (c *commandDialogCmp) next() commandType {
-	switch c.selected {
-	case SystemCommands:
-		if len(c.userCommands) > 0 {
-			return UserCommands
-		}
-		if c.mcpPrompts.Len() > 0 {
-			return MCPPrompts
-		}
-		fallthrough
-	case UserCommands:
-		if c.mcpPrompts.Len() > 0 {
-			return MCPPrompts
-		}
-		fallthrough
-	case MCPPrompts:
-		return SystemCommands
-	default:
-		return SystemCommands
-	}
-}
-
-func (c *commandDialogCmp) View() string {
-	t := styles.CurrentTheme()
-	listView := c.commandList
-	radio := c.commandTypeRadio()
-
-	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
-	if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
-		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
-	}
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		header,
-		listView.View(),
-		"",
-		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
-	)
-	return c.style().Render(content)
-}
-
-func (c *commandDialogCmp) Cursor() *tea.Cursor {
-	if cursor, ok := c.commandList.(util.Cursor); ok {
-		cursor := cursor.Cursor()
-		if cursor != nil {
-			cursor = c.moveCursor(cursor)
-		}
-		return cursor
-	}
-	return nil
-}
-
-func (c *commandDialogCmp) commandTypeRadio() string {
-	t := styles.CurrentTheme()
-
-	fn := func(i commandType) string {
-		if i == c.selected {
-			return "◉ " + i.String()
-		}
-		return "○ " + i.String()
-	}
-
-	parts := []string{
-		fn(SystemCommands),
-	}
-	if len(c.userCommands) > 0 {
-		parts = append(parts, fn(UserCommands))
-	}
-	if c.mcpPrompts.Len() > 0 {
-		parts = append(parts, fn(MCPPrompts))
-	}
-	return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
-}
-
-func (c *commandDialogCmp) listWidth() int {
-	return defaultWidth - 2 // 4 for padding
-}
-
-func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
-	c.selected = commandType
-
-	var commands []Command
-	switch c.selected {
-	case SystemCommands:
-		commands = c.defaultCommands()
-	case UserCommands:
-		commands = c.userCommands
-	case MCPPrompts:
-		commands = slices.Collect(c.mcpPrompts.Seq())
-	}
-
-	commandItems := []list.CompletionItem[Command]{}
-	for _, cmd := range commands {
-		opts := []list.CompletionItemOption{
-			list.WithCompletionID(cmd.ID),
-		}
-		if cmd.Shortcut != "" {
-			opts = append(
-				opts,
-				list.WithCompletionShortcut(cmd.Shortcut),
-			)
-		}
-		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
-	}
-	return c.commandList.SetItems(commandItems)
-}
-
-func (c *commandDialogCmp) listHeight() int {
-	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
-	return min(listHeigh, c.wHeight/2)
-}
-
-func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
-	row, col := c.Position()
-	offset := row + 3
-	cursor.Y += offset
-	cursor.X = cursor.X + col + 2
-	return cursor
-}
-
-func (c *commandDialogCmp) style() lipgloss.Style {
-	t := styles.CurrentTheme()
-	return t.S().Base.
-		Width(c.width).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus)
-}
-
-func (c *commandDialogCmp) Position() (int, int) {
-	row := c.wHeight/4 - 2 // just a bit above the center
-	col := c.wWidth / 2
-	col -= c.width / 2
-	return row, col
-}
-
-func (c *commandDialogCmp) defaultCommands() []Command {
-	commands := []Command{
-		{
-			ID:          "new_session",
-			Title:       "New Session",
-			Description: "start a new session",
-			Shortcut:    "ctrl+n",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(NewSessionsMsg{})
-			},
-		},
-		{
-			ID:          "switch_session",
-			Title:       "Switch Session",
-			Description: "Switch to a different session",
-			Shortcut:    "ctrl+s",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(SwitchSessionsMsg{})
-			},
-		},
-		{
-			ID:          "switch_model",
-			Title:       "Switch Model",
-			Description: "Switch to a different model",
-			Shortcut:    "ctrl+l",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(SwitchModelMsg{})
-			},
-		},
-	}
-
-	// Only show compact command if there's an active session
-	if c.sessionID != "" {
-		commands = append(commands, Command{
-			ID:          "Summarize",
-			Title:       "Summarize Session",
-			Description: "Summarize the current session and create a new one with the summary",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(CompactMsg{
-					SessionID: c.sessionID,
-				})
-			},
-		})
-	}
-
-	// Add reasoning toggle for models that support it
-	cfg := config.Get()
-	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
-		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
-		model := cfg.GetModelByType(agentCfg.Model)
-		if providerCfg != nil && model != nil && model.CanReason {
-			selectedModel := cfg.Models[agentCfg.Model]
-
-			// Anthropic models: thinking toggle
-			if model.CanReason && len(model.ReasoningLevels) == 0 {
-				status := "Enable"
-				if selectedModel.Think {
-					status = "Disable"
-				}
-				commands = append(commands, Command{
-					ID:          "toggle_thinking",
-					Title:       status + " Thinking Mode",
-					Description: "Toggle model thinking for reasoning-capable models",
-					Handler: func(cmd Command) tea.Cmd {
-						return util.CmdHandler(ToggleThinkingMsg{})
-					},
-				})
-			}
-
-			// OpenAI models: reasoning effort dialog
-			if len(model.ReasoningLevels) > 0 {
-				commands = append(commands, Command{
-					ID:          "select_reasoning_effort",
-					Title:       "Select Reasoning Effort",
-					Description: "Choose reasoning effort level (low/medium/high)",
-					Handler: func(cmd Command) tea.Cmd {
-						return util.CmdHandler(OpenReasoningDialogMsg{})
-					},
-				})
-			}
-		}
-	}
-	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
-	if c.wWidth > 120 && c.sessionID != "" {
-		commands = append(commands, Command{
-			ID:          "toggle_sidebar",
-			Title:       "Toggle Sidebar",
-			Description: "Toggle between compact and normal layout",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(ToggleCompactModeMsg{})
-			},
-		})
-	}
-	if c.sessionID != "" {
-		agentCfg := config.Get().Agents[config.AgentCoder]
-		model := config.Get().GetModelByType(agentCfg.Model)
-		if model.SupportsImages {
-			commands = append(commands, Command{
-				ID:          "file_picker",
-				Title:       "Open File Picker",
-				Shortcut:    "ctrl+f",
-				Description: "Open file picker",
-				Handler: func(cmd Command) tea.Cmd {
-					return util.CmdHandler(OpenFilePickerMsg{})
-				},
-			})
-		}
-	}
-
-	// Add external editor command if $EDITOR is available
-	if os.Getenv("EDITOR") != "" {
-		commands = append(commands, Command{
-			ID:          "open_external_editor",
-			Title:       "Open External Editor",
-			Shortcut:    "ctrl+o",
-			Description: "Open external editor to compose message",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(OpenExternalEditorMsg{})
-			},
-		})
-	}
-
-	return append(commands, []Command{
-		{
-			ID:          "toggle_yolo",
-			Title:       "Toggle Yolo Mode",
-			Description: "Toggle yolo mode",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(ToggleYoloModeMsg{})
-			},
-		},
-		{
-			ID:          "toggle_help",
-			Title:       "Toggle Help",
-			Shortcut:    "ctrl+g",
-			Description: "Toggle help",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(ToggleHelpMsg{})
-			},
-		},
-		{
-			ID:          "init",
-			Title:       "Initialize Project",
-			Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
-			Handler: func(cmd Command) tea.Cmd {
-				initPrompt, err := agent.InitializePrompt(*config.Get())
-				if err != nil {
-					return util.ReportError(err)
-				}
-				return util.CmdHandler(chat.SendMsg{
-					Text: initPrompt,
-				})
-			},
-		},
-		{
-			ID:          "quit",
-			Title:       "Quit",
-			Description: "Quit",
-			Shortcut:    "ctrl+c",
-			Handler: func(cmd Command) tea.Cmd {
-				return util.CmdHandler(QuitMsg{})
-			},
-		},
-	}...)
-}
-
-func (c *commandDialogCmp) ID() dialogs.DialogID {
-	return CommandsDialogID
-}

internal/tui/components/dialogs/commands/keys.go 🔗

@@ -1,133 +0,0 @@
-package commands
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type CommandsDialogKeyMap struct {
-	Select,
-	Next,
-	Previous,
-	Tab,
-	Close key.Binding
-}
-
-func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
-	return CommandsDialogKeyMap{
-		Select: key.NewBinding(
-			key.WithKeys("enter", "ctrl+y"),
-			key.WithHelp("enter", "confirm"),
-		),
-		Next: key.NewBinding(
-			key.WithKeys("down", "ctrl+n"),
-			key.WithHelp("↓", "next item"),
-		),
-		Previous: key.NewBinding(
-			key.WithKeys("up", "ctrl+p"),
-			key.WithHelp("↑", "previous item"),
-		),
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", "switch selection"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k CommandsDialogKeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Select,
-		k.Next,
-		k.Previous,
-		k.Tab,
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.Tab,
-		key.NewBinding(
-			key.WithKeys("down", "up"),
-			key.WithHelp("↑↓", "choose"),
-		),
-		k.Select,
-		k.Close,
-	}
-}
-
-type ArgumentsDialogKeyMap struct {
-	Confirm  key.Binding
-	Next     key.Binding
-	Previous key.Binding
-	Close    key.Binding
-}
-
-func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
-	return ArgumentsDialogKeyMap{
-		Confirm: key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "confirm"),
-		),
-
-		Next: key.NewBinding(
-			key.WithKeys("tab", "down"),
-			key.WithHelp("tab/↓", "next"),
-		),
-		Previous: key.NewBinding(
-			key.WithKeys("shift+tab", "up"),
-			key.WithHelp("shift+tab/↑", "previous"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Confirm,
-		k.Next,
-		k.Previous,
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.Confirm,
-		k.Next,
-		k.Previous,
-		k.Close,
-	}
-}

internal/tui/components/dialogs/copilot/device_flow.go 🔗

@@ -1,281 +0,0 @@
-// Package copilot provides the dialog for Copilot device flow authentication.
-package copilot
-
-import (
-	"context"
-	"fmt"
-	"time"
-
-	"charm.land/bubbles/v2/spinner"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/oauth"
-	"github.com/charmbracelet/crush/internal/oauth/copilot"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/pkg/browser"
-)
-
-// DeviceFlowState represents the current state of the device flow.
-type DeviceFlowState int
-
-const (
-	DeviceFlowStateDisplay DeviceFlowState = iota
-	DeviceFlowStateSuccess
-	DeviceFlowStateError
-	DeviceFlowStateUnavailable
-)
-
-// DeviceAuthInitiatedMsg is sent when the device auth is initiated
-// successfully.
-type DeviceAuthInitiatedMsg struct {
-	deviceCode *copilot.DeviceCode
-}
-
-// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
-type DeviceFlowCompletedMsg struct {
-	Token *oauth.Token
-}
-
-// DeviceFlowErrorMsg is sent when the device flow encounters an error.
-type DeviceFlowErrorMsg struct {
-	Error error
-}
-
-// DeviceFlow handles the Copilot device flow authentication.
-type DeviceFlow struct {
-	State      DeviceFlowState
-	width      int
-	deviceCode *copilot.DeviceCode
-	token      *oauth.Token
-	cancelFunc context.CancelFunc
-	spinner    spinner.Model
-}
-
-// NewDeviceFlow creates a new device flow component.
-func NewDeviceFlow() *DeviceFlow {
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
-	return &DeviceFlow{
-		State:   DeviceFlowStateDisplay,
-		spinner: s,
-	}
-}
-
-// Init initializes the device flow by calling the device auth API and starting polling.
-func (d *DeviceFlow) Init() tea.Cmd {
-	return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
-}
-
-// Update handles messages and state transitions.
-func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	d.spinner, cmd = d.spinner.Update(msg)
-
-	switch msg := msg.(type) {
-	case DeviceAuthInitiatedMsg:
-		return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
-	case DeviceFlowCompletedMsg:
-		d.State = DeviceFlowStateSuccess
-		d.token = msg.Token
-		return d, nil
-	case DeviceFlowErrorMsg:
-		switch msg.Error {
-		case copilot.ErrNotAvailable:
-			d.State = DeviceFlowStateUnavailable
-		default:
-			d.State = DeviceFlowStateError
-		}
-		return d, nil
-	}
-
-	return d, cmd
-}
-
-// View renders the device flow dialog.
-func (d *DeviceFlow) View() string {
-	t := styles.CurrentTheme()
-
-	whiteStyle := lipgloss.NewStyle().Foreground(t.White)
-	primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
-	greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
-	linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
-	errorStyle := lipgloss.NewStyle().Foreground(t.Error)
-	mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
-
-	switch d.State {
-	case DeviceFlowStateDisplay:
-		if d.deviceCode == nil {
-			return lipgloss.NewStyle().
-				Margin(0, 1).
-				Render(
-					greenStyle.Render(d.spinner.View()) +
-						mutedStyle.Render("Initializing..."),
-				)
-		}
-
-		instructions := lipgloss.NewStyle().
-			Margin(1, 1, 0, 1).
-			Width(d.width - 2).
-			Render(
-				whiteStyle.Render("Press ") +
-					primaryStyle.Render("enter") +
-					whiteStyle.Render(" to copy the code below and open the browser."),
-			)
-
-		codeBox := lipgloss.NewStyle().
-			Width(d.width-2).
-			Height(7).
-			Align(lipgloss.Center, lipgloss.Center).
-			Background(t.BgBaseLighter).
-			Margin(1).
-			Render(
-				lipgloss.NewStyle().
-					Bold(true).
-					Foreground(t.White).
-					Render(d.deviceCode.UserCode),
-			)
-
-		uri := d.deviceCode.VerificationURI
-		link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri)
-		url := mutedStyle.
-			Margin(0, 1).
-			Width(d.width - 2).
-			Render("Browser not opening? Refer to\n" + link)
-
-		waiting := greenStyle.
-			Width(d.width-2).
-			Margin(1, 1, 0, 1).
-			Render(d.spinner.View() + "Verifying...")
-
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			instructions,
-			codeBox,
-			url,
-			waiting,
-		)
-
-	case DeviceFlowStateSuccess:
-		return greenStyle.Margin(0, 1).Render("Authentication successful!")
-
-	case DeviceFlowStateError:
-		return lipgloss.NewStyle().
-			Margin(0, 1).
-			Width(d.width - 2).
-			Render(errorStyle.Render("Authentication failed."))
-
-	case DeviceFlowStateUnavailable:
-		message := lipgloss.NewStyle().
-			Margin(0, 1).
-			Width(d.width - 2).
-			Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
-		freeMessage := lipgloss.NewStyle().
-			Margin(0, 1).
-			Width(d.width - 2).
-			Render("You may be able to request free access if eligible. For more information, see:")
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			message,
-			"",
-			linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL),
-			"",
-			freeMessage,
-			"",
-			linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL),
-		)
-
-	default:
-		return ""
-	}
-}
-
-// SetWidth sets the width of the dialog.
-func (d *DeviceFlow) SetWidth(w int) {
-	d.width = w
-}
-
-// Cursor hides the cursor.
-func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
-
-// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
-func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
-	switch d.State {
-	case DeviceFlowStateDisplay:
-		return tea.Sequence(
-			tea.SetClipboard(d.deviceCode.UserCode),
-			func() tea.Msg {
-				if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil {
-					return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
-				}
-				return nil
-			},
-			util.ReportInfo("Code copied and URL opened"),
-		)
-	case DeviceFlowStateUnavailable:
-		return tea.Sequence(
-			func() tea.Msg {
-				if err := browser.OpenURL(copilot.SignupURL); err != nil {
-					return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
-				}
-				return nil
-			},
-			util.ReportInfo("Code copied and URL opened"),
-		)
-	default:
-		return nil
-	}
-}
-
-// CopyCode copies just the user code to the clipboard.
-func (d *DeviceFlow) CopyCode() tea.Cmd {
-	if d.State != DeviceFlowStateDisplay {
-		return nil
-	}
-	return tea.Sequence(
-		tea.SetClipboard(d.deviceCode.UserCode),
-		util.ReportInfo("Code copied to clipboard"),
-	)
-}
-
-// Cancel cancels the device flow polling.
-func (d *DeviceFlow) Cancel() {
-	if d.cancelFunc != nil {
-		d.cancelFunc()
-	}
-}
-
-func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
-	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-	defer cancel()
-
-	deviceCode, err := copilot.RequestDeviceCode(ctx)
-	if err != nil {
-		return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
-	}
-
-	d.deviceCode = deviceCode
-
-	return DeviceAuthInitiatedMsg{
-		deviceCode: d.deviceCode,
-	}
-}
-
-// startPolling starts polling for the device token.
-func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd {
-	return func() tea.Msg {
-		ctx, cancel := context.WithCancel(context.Background())
-		d.cancelFunc = cancel
-
-		token, err := copilot.PollForToken(ctx, deviceCode)
-		if err != nil {
-			if ctx.Err() != nil {
-				return nil // cancelled, don't report error.
-			}
-			return DeviceFlowErrorMsg{Error: err}
-		}
-
-		return DeviceFlowCompletedMsg{Token: token}
-	}
-}

internal/tui/components/dialogs/dialogs.go 🔗

@@ -1,165 +0,0 @@
-package dialogs
-
-import (
-	"slices"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type DialogID string
-
-// DialogModel represents a dialog component that can be displayed.
-type DialogModel interface {
-	util.Model
-	Position() (int, int)
-	ID() DialogID
-}
-
-// CloseCallback allows dialogs to perform cleanup when closed.
-type CloseCallback interface {
-	Close() tea.Cmd
-}
-
-// OpenDialogMsg is sent to open a new dialog with specified dimensions.
-type OpenDialogMsg struct {
-	Model DialogModel
-}
-
-// CloseDialogMsg is sent to close the topmost dialog.
-type CloseDialogMsg struct{}
-
-// DialogCmp manages a stack of dialogs with keyboard navigation.
-type DialogCmp interface {
-	util.Model
-
-	Dialogs() []DialogModel
-	HasDialogs() bool
-	GetLayers() []*lipgloss.Layer
-	ActiveModel() util.Model
-	ActiveDialogID() DialogID
-}
-
-type dialogCmp struct {
-	width, height int
-	dialogs       []DialogModel
-	idMap         map[DialogID]int
-	keyMap        KeyMap
-}
-
-// NewDialogCmp creates a new dialog manager.
-func NewDialogCmp() DialogCmp {
-	return dialogCmp{
-		dialogs: []DialogModel{},
-		keyMap:  DefaultKeyMap(),
-		idMap:   make(map[DialogID]int),
-	}
-}
-
-func (d dialogCmp) Init() tea.Cmd {
-	return nil
-}
-
-// Update handles dialog lifecycle and forwards messages to the active dialog.
-func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		var cmds []tea.Cmd
-		d.width = msg.Width
-		d.height = msg.Height
-		for i := range d.dialogs {
-			u, cmd := d.dialogs[i].Update(msg)
-			d.dialogs[i] = u.(DialogModel)
-			cmds = append(cmds, cmd)
-		}
-		return d, tea.Batch(cmds...)
-	case OpenDialogMsg:
-		return d.handleOpen(msg)
-	case CloseDialogMsg:
-		if len(d.dialogs) == 0 {
-			return d, nil
-		}
-		inx := len(d.dialogs) - 1
-		dialog := d.dialogs[inx]
-		delete(d.idMap, dialog.ID())
-		d.dialogs = d.dialogs[:len(d.dialogs)-1]
-		if closeable, ok := dialog.(CloseCallback); ok {
-			return d, closeable.Close()
-		}
-		return d, nil
-	}
-	if d.HasDialogs() {
-		lastIndex := len(d.dialogs) - 1
-		u, cmd := d.dialogs[lastIndex].Update(msg)
-		d.dialogs[lastIndex] = u.(DialogModel)
-		return d, cmd
-	}
-	return d, nil
-}
-
-func (d dialogCmp) View() string {
-	return ""
-}
-
-func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) {
-	if d.HasDialogs() {
-		dialog := d.dialogs[len(d.dialogs)-1]
-		if dialog.ID() == msg.Model.ID() {
-			return d, nil // Do not open a dialog if it's already the topmost one
-		}
-		if dialog.ID() == "quit" {
-			return d, nil // Do not open dialogs on top of quit
-		}
-	}
-	// if the dialog is already in the stack make it the last item
-	if _, ok := d.idMap[msg.Model.ID()]; ok {
-		existing := d.dialogs[d.idMap[msg.Model.ID()]]
-		// Reuse the model so we keep the state
-		msg.Model = existing
-		d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
-	}
-	d.idMap[msg.Model.ID()] = len(d.dialogs)
-	d.dialogs = append(d.dialogs, msg.Model)
-	var cmds []tea.Cmd
-	cmd := msg.Model.Init()
-	cmds = append(cmds, cmd)
-	_, cmd = msg.Model.Update(tea.WindowSizeMsg{
-		Width:  d.width,
-		Height: d.height,
-	})
-	cmds = append(cmds, cmd)
-	return d, tea.Batch(cmds...)
-}
-
-func (d dialogCmp) Dialogs() []DialogModel {
-	return d.dialogs
-}
-
-func (d dialogCmp) ActiveModel() util.Model {
-	if len(d.dialogs) == 0 {
-		return nil
-	}
-	return d.dialogs[len(d.dialogs)-1]
-}
-
-func (d dialogCmp) ActiveDialogID() DialogID {
-	if len(d.dialogs) == 0 {
-		return ""
-	}
-	return d.dialogs[len(d.dialogs)-1].ID()
-}
-
-func (d dialogCmp) GetLayers() []*lipgloss.Layer {
-	layers := []*lipgloss.Layer{}
-	for _, dialog := range d.Dialogs() {
-		dialogView := dialog.View()
-		row, col := dialog.Position()
-		layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
-	}
-	return layers
-}
-
-func (d dialogCmp) HasDialogs() bool {
-	return len(d.dialogs) > 0
-}

internal/tui/components/dialogs/filepicker/filepicker.go 🔗

@@ -1,260 +0,0 @@
-package filepicker
-
-import (
-	"fmt"
-	"net/http"
-	"os"
-	"path/filepath"
-	"strings"
-
-	"charm.land/bubbles/v2/filepicker"
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/components/image"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
-	MaxAttachmentSize   = int64(5 * 1024 * 1024) // 5MB
-	FilePickerID        = "filepicker"
-	fileSelectionHeight = 10
-	previewHeight       = 20
-)
-
-type FilePickedMsg struct {
-	Attachment message.Attachment
-}
-
-type FilePicker interface {
-	dialogs.DialogModel
-}
-
-type model struct {
-	wWidth          int
-	wHeight         int
-	width           int
-	filePicker      filepicker.Model
-	highlightedFile string
-	image           image.Model
-	keyMap          KeyMap
-	help            help.Model
-}
-
-var AllowedTypes = []string{".jpg", ".jpeg", ".png"}
-
-func NewFilePickerCmp(workingDir string) FilePicker {
-	t := styles.CurrentTheme()
-	fp := filepicker.New()
-	fp.AllowedTypes = AllowedTypes
-
-	if workingDir != "" {
-		fp.CurrentDirectory = workingDir
-	} else {
-		// Fallback to current working directory, then home directory
-		if cwd, err := os.Getwd(); err == nil {
-			fp.CurrentDirectory = cwd
-		} else {
-			fp.CurrentDirectory = home.Dir()
-		}
-	}
-
-	fp.ShowPermissions = false
-	fp.ShowSize = false
-	fp.AutoHeight = false
-	fp.Styles = t.S().FilePicker
-	fp.Cursor = ""
-	fp.SetHeight(fileSelectionHeight)
-
-	image := image.New(1, 1, "")
-
-	help := help.New()
-	help.Styles = t.S().Help
-	return &model{
-		filePicker: fp,
-		image:      image,
-		keyMap:     DefaultKeyMap(),
-		help:       help,
-	}
-}
-
-func (m *model) Init() tea.Cmd {
-	return m.filePicker.Init()
-}
-
-func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		m.wWidth = msg.Width
-		m.wHeight = msg.Height
-		m.width = min(70, m.wWidth)
-		styles := m.filePicker.Styles
-		styles.Directory = styles.Directory.Width(m.width - 4)
-		styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
-		styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
-		styles.File = styles.File.Width(m.width)
-		m.filePicker.Styles = styles
-		return m, nil
-	case tea.KeyPressMsg:
-		if key.Matches(msg, m.keyMap.Close) {
-			return m, util.CmdHandler(dialogs.CloseDialogMsg{})
-		}
-		if key.Matches(msg, m.filePicker.KeyMap.Back) {
-			// make sure we don't go back if we are at the home directory
-			if m.filePicker.CurrentDirectory == home.Dir() {
-				return m, nil
-			}
-		}
-	}
-
-	var cmd tea.Cmd
-	var cmds []tea.Cmd
-	m.filePicker, cmd = m.filePicker.Update(msg)
-	cmds = append(cmds, cmd)
-	if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
-		w, h := m.imagePreviewSize()
-		cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
-		cmds = append(cmds, cmd)
-	}
-	m.highlightedFile = m.currentImage()
-
-	// Did the user select a file?
-	if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
-		// Get the path of the selected file.
-		return m, tea.Sequence(
-			util.CmdHandler(dialogs.CloseDialogMsg{}),
-			func() tea.Msg {
-				isFileLarge, err := IsFileTooBig(path, MaxAttachmentSize)
-				if err != nil {
-					return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
-				}
-				if isFileLarge {
-					return util.ReportError(fmt.Errorf("file too large, max 5MB"))
-				}
-
-				content, err := os.ReadFile(path)
-				if err != nil {
-					return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
-				}
-
-				mimeBufferSize := min(512, len(content))
-				mimeType := http.DetectContentType(content[:mimeBufferSize])
-				fileName := filepath.Base(path)
-				attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
-				return FilePickedMsg{
-					Attachment: attachment,
-				}
-			},
-		)
-	}
-	m.image, cmd = m.image.Update(msg)
-	cmds = append(cmds, cmd)
-	return m, tea.Batch(cmds...)
-}
-
-func (m *model) View() string {
-	t := styles.CurrentTheme()
-
-	strs := []string{
-		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
-	}
-
-	// hide image preview if the terminal is too small
-	if x, y := m.imagePreviewSize(); x > 0 && y > 0 {
-		strs = append(strs, m.imagePreview())
-	}
-
-	strs = append(
-		strs,
-		m.filePicker.View(),
-		t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
-	)
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		strs...,
-	)
-	return m.style().Render(content)
-}
-
-func (m *model) currentImage() string {
-	for _, ext := range m.filePicker.AllowedTypes {
-		if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
-			return m.filePicker.HighlightedPath()
-		}
-	}
-	return ""
-}
-
-func (m *model) imagePreview() string {
-	const padding = 2
-
-	t := styles.CurrentTheme()
-	w, h := m.imagePreviewSize()
-
-	if m.currentImage() == "" {
-		imgPreview := t.S().Base.
-			Width(w - padding).
-			Height(h - padding).
-			Background(t.BgOverlay)
-
-		return m.imagePreviewStyle().Render(imgPreview.Render())
-	}
-
-	return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
-}
-
-func (m *model) imagePreviewStyle() lipgloss.Style {
-	t := styles.CurrentTheme()
-	return t.S().Base.Padding(1, 1, 1, 1)
-}
-
-func (m *model) imagePreviewSize() (int, int) {
-	if m.wHeight-fileSelectionHeight-8 > previewHeight {
-		return m.width - 4, previewHeight
-	}
-	return 0, 0
-}
-
-func (m *model) style() lipgloss.Style {
-	t := styles.CurrentTheme()
-	return t.S().Base.
-		Width(m.width).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus)
-}
-
-// ID implements FilePicker.
-func (m *model) ID() dialogs.DialogID {
-	return FilePickerID
-}
-
-// Position implements FilePicker.
-func (m *model) Position() (int, int) {
-	_, imageHeight := m.imagePreviewSize()
-	dialogHeight := fileSelectionHeight + imageHeight + 4
-	row := (m.wHeight - dialogHeight) / 2
-
-	col := m.wWidth / 2
-	col -= m.width / 2
-	return row, col
-}
-
-func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
-	fileInfo, err := os.Stat(filePath)
-	if err != nil {
-		return false, fmt.Errorf("error getting file info: %w", err)
-	}
-
-	if fileInfo.Size() > sizeLimit {
-		return true, nil
-	}
-
-	return false, nil
-}

internal/tui/components/dialogs/filepicker/keys.go 🔗

@@ -1,80 +0,0 @@
-package filepicker
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-// KeyMap defines keyboard bindings for dialog management.
-type KeyMap struct {
-	Select,
-	Down,
-	Up,
-	Forward,
-	Backward,
-	Close key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Select: key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "accept"),
-		),
-		Down: key.NewBinding(
-			key.WithKeys("down", "j"),
-			key.WithHelp("down/j", "move down"),
-		),
-		Up: key.NewBinding(
-			key.WithKeys("up", "k"),
-			key.WithHelp("up/k", "move up"),
-		),
-		Forward: key.NewBinding(
-			key.WithKeys("right", "l"),
-			key.WithHelp("right/l", "move forward"),
-		),
-		Backward: key.NewBinding(
-			key.WithKeys("left", "h"),
-			key.WithHelp("left/h", "move backward"),
-		),
-
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "close/exit"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Select,
-		k.Down,
-		k.Up,
-		k.Forward,
-		k.Backward,
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		key.NewBinding(
-			key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
-			key.WithHelp("↑↓←→", "navigate"),
-		),
-		k.Select,
-		k.Close,
-	}
-}

internal/tui/components/dialogs/hyper/device_flow.go 🔗

@@ -1,267 +0,0 @@
-// Package hyper provides the dialog for Hyper device flow authentication.
-package hyper
-
-import (
-	"context"
-	"fmt"
-	"time"
-
-	"charm.land/bubbles/v2/spinner"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/oauth"
-	"github.com/charmbracelet/crush/internal/oauth/hyper"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/pkg/browser"
-)
-
-// DeviceFlowState represents the current state of the device flow.
-type DeviceFlowState int
-
-const (
-	DeviceFlowStateDisplay DeviceFlowState = iota
-	DeviceFlowStateSuccess
-	DeviceFlowStateError
-)
-
-// DeviceAuthInitiatedMsg is sent when the device auth is initiated
-// successfully.
-type DeviceAuthInitiatedMsg struct {
-	deviceCode string
-	expiresIn  int
-}
-
-// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
-type DeviceFlowCompletedMsg struct {
-	Token *oauth.Token
-}
-
-// DeviceFlowErrorMsg is sent when the device flow encounters an error.
-type DeviceFlowErrorMsg struct {
-	Error error
-}
-
-// DeviceFlow handles the Hyper device flow authentication.
-type DeviceFlow struct {
-	State           DeviceFlowState
-	width           int
-	deviceCode      string
-	userCode        string
-	verificationURL string
-	expiresIn       int
-	token           *oauth.Token
-	cancelFunc      context.CancelFunc
-	spinner         spinner.Model
-}
-
-// NewDeviceFlow creates a new device flow component.
-func NewDeviceFlow() *DeviceFlow {
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
-	return &DeviceFlow{
-		State:   DeviceFlowStateDisplay,
-		spinner: s,
-	}
-}
-
-// Init initializes the device flow by calling the device auth API and starting polling.
-func (d *DeviceFlow) Init() tea.Cmd {
-	return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
-}
-
-// Update handles messages and state transitions.
-func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	d.spinner, cmd = d.spinner.Update(msg)
-
-	switch msg := msg.(type) {
-	case DeviceAuthInitiatedMsg:
-		// Start polling now that we have the device code.
-		d.expiresIn = msg.expiresIn
-		return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
-	case DeviceFlowCompletedMsg:
-		d.State = DeviceFlowStateSuccess
-		d.token = msg.Token
-		return d, nil
-	case DeviceFlowErrorMsg:
-		d.State = DeviceFlowStateError
-		return d, util.ReportError(msg.Error)
-	}
-
-	return d, cmd
-}
-
-// View renders the device flow dialog.
-func (d *DeviceFlow) View() string {
-	t := styles.CurrentTheme()
-
-	whiteStyle := lipgloss.NewStyle().Foreground(t.White)
-	primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
-	greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
-	linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
-	errorStyle := lipgloss.NewStyle().Foreground(t.Error)
-	mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
-
-	switch d.State {
-	case DeviceFlowStateDisplay:
-		if d.userCode == "" {
-			return lipgloss.NewStyle().
-				Margin(0, 1).
-				Render(
-					greenStyle.Render(d.spinner.View()) +
-						mutedStyle.Render("Initializing..."),
-				)
-		}
-
-		instructions := lipgloss.NewStyle().
-			Margin(1, 1, 0, 1).
-			Width(d.width - 2).
-			Render(
-				whiteStyle.Render("Press ") +
-					primaryStyle.Render("enter") +
-					whiteStyle.Render(" to copy the code below and open the browser."),
-			)
-
-		codeBox := lipgloss.NewStyle().
-			Width(d.width-2).
-			Height(7).
-			Align(lipgloss.Center, lipgloss.Center).
-			Background(t.BgBaseLighter).
-			Margin(1).
-			Render(
-				lipgloss.NewStyle().
-					Bold(true).
-					Foreground(t.White).
-					Render(d.userCode),
-			)
-
-		link := linkStyle.Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL)
-		url := mutedStyle.
-			Margin(0, 1).
-			Width(d.width - 2).
-			Render("Browser not opening? Refer to\n" + link)
-
-		waiting := greenStyle.
-			Width(d.width-2).
-			Margin(1, 1, 0, 1).
-			Render(d.spinner.View() + "Verifying...")
-
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			instructions,
-			codeBox,
-			url,
-			waiting,
-		)
-
-	case DeviceFlowStateSuccess:
-		return greenStyle.Margin(0, 1).Render("Authentication successful!")
-
-	case DeviceFlowStateError:
-		return lipgloss.NewStyle().
-			Margin(0, 1).
-			Width(d.width - 2).
-			Render(errorStyle.Render("Authentication failed."))
-
-	default:
-		return ""
-	}
-}
-
-// SetWidth sets the width of the dialog.
-func (d *DeviceFlow) SetWidth(w int) {
-	d.width = w
-}
-
-// Cursor hides the cursor.
-func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
-
-// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
-func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
-	if d.State != DeviceFlowStateDisplay {
-		return nil
-	}
-	return tea.Sequence(
-		tea.SetClipboard(d.userCode),
-		func() tea.Msg {
-			if err := browser.OpenURL(d.verificationURL); err != nil {
-				return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
-			}
-			return nil
-		},
-		util.ReportInfo("Code copied and URL opened"),
-	)
-}
-
-// CopyCode copies just the user code to the clipboard.
-func (d *DeviceFlow) CopyCode() tea.Cmd {
-	if d.State != DeviceFlowStateDisplay {
-		return nil
-	}
-	return tea.Sequence(
-		tea.SetClipboard(d.userCode),
-		util.ReportInfo("Code copied to clipboard"),
-	)
-}
-
-// Cancel cancels the device flow polling.
-func (d *DeviceFlow) Cancel() {
-	if d.cancelFunc != nil {
-		d.cancelFunc()
-	}
-}
-
-func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
-	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-	defer cancel()
-	authResp, err := hyper.InitiateDeviceAuth(ctx)
-	if err != nil {
-		return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
-	}
-
-	d.deviceCode = authResp.DeviceCode
-	d.userCode = authResp.UserCode
-	d.verificationURL = authResp.VerificationURL
-
-	return DeviceAuthInitiatedMsg{
-		deviceCode: authResp.DeviceCode,
-		expiresIn:  authResp.ExpiresIn,
-	}
-}
-
-// startPolling starts polling for the device token.
-func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd {
-	return func() tea.Msg {
-		ctx, cancel := context.WithCancel(context.Background())
-		d.cancelFunc = cancel
-
-		// Poll for refresh token.
-		refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn)
-		if err != nil {
-			if ctx.Err() != nil {
-				// Cancelled, don't report error.
-				return nil
-			}
-			return DeviceFlowErrorMsg{Error: err}
-		}
-
-		// Exchange refresh token for access token.
-		token, err := hyper.ExchangeToken(ctx, refreshToken)
-		if err != nil {
-			return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)}
-		}
-
-		// Verify the access token works.
-		introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
-		if err != nil {
-			return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)}
-		}
-		if !introspect.Active {
-			return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")}
-		}
-
-		return DeviceFlowCompletedMsg{Token: token}
-	}
-}

internal/tui/components/dialogs/keys.go 🔗

@@ -1,43 +0,0 @@
-package dialogs
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-// KeyMap defines keyboard bindings for dialog management.
-type KeyMap struct {
-	Close key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.Close,
-	}
-}

internal/tui/components/dialogs/models/apikey.go 🔗

@@ -1,203 +0,0 @@
-package models
-
-import (
-	"fmt"
-
-	"charm.land/bubbles/v2/spinner"
-	"charm.land/bubbles/v2/textinput"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type APIKeyInputState int
-
-const (
-	APIKeyInputStateInitial APIKeyInputState = iota
-	APIKeyInputStateVerifying
-	APIKeyInputStateVerified
-	APIKeyInputStateError
-)
-
-type APIKeyStateChangeMsg struct {
-	State APIKeyInputState
-}
-
-type APIKeyInput struct {
-	input        textinput.Model
-	width        int
-	spinner      spinner.Model
-	providerName string
-	state        APIKeyInputState
-	title        string
-	showTitle    bool
-}
-
-func NewAPIKeyInput() *APIKeyInput {
-	t := styles.CurrentTheme()
-
-	ti := textinput.New()
-	ti.Placeholder = "Enter your API key..."
-	ti.SetVirtualCursor(false)
-	ti.Prompt = "> "
-	ti.SetStyles(t.S().TextInput)
-	ti.Focus()
-
-	return &APIKeyInput{
-		input: ti,
-		state: APIKeyInputStateInitial,
-		spinner: spinner.New(
-			spinner.WithSpinner(spinner.Dot),
-			spinner.WithStyle(t.S().Base.Foreground(t.Green)),
-		),
-		providerName: "Provider",
-		showTitle:    true,
-	}
-}
-
-func (a *APIKeyInput) SetProviderName(name string) {
-	a.providerName = name
-	a.updateStatePresentation()
-}
-
-func (a *APIKeyInput) SetShowTitle(show bool) {
-	a.showTitle = show
-}
-
-func (a *APIKeyInput) GetTitle() string {
-	return a.title
-}
-
-func (a *APIKeyInput) Init() tea.Cmd {
-	a.updateStatePresentation()
-	return a.spinner.Tick
-}
-
-func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case spinner.TickMsg:
-		if a.state == APIKeyInputStateVerifying {
-			var cmd tea.Cmd
-			a.spinner, cmd = a.spinner.Update(msg)
-			a.updateStatePresentation()
-			return a, cmd
-		}
-		return a, nil
-	case APIKeyStateChangeMsg:
-		a.state = msg.State
-		var cmd tea.Cmd
-		if msg.State == APIKeyInputStateVerifying {
-			cmd = a.spinner.Tick
-		}
-		a.updateStatePresentation()
-		return a, cmd
-	}
-
-	var cmd tea.Cmd
-	a.input, cmd = a.input.Update(msg)
-	return a, cmd
-}
-
-func (a *APIKeyInput) updateStatePresentation() {
-	t := styles.CurrentTheme()
-
-	prefixStyle := t.S().Base.
-		Foreground(t.Primary)
-	accentStyle := t.S().Base.Foreground(t.Green).Bold(true)
-	errorStyle := t.S().Base.Foreground(t.Cherry)
-
-	switch a.state {
-	case APIKeyInputStateInitial:
-		titlePrefix := prefixStyle.Render("Enter your ")
-		a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(".")
-		a.input.SetStyles(t.S().TextInput)
-		a.input.Prompt = "> "
-	case APIKeyInputStateVerifying:
-		titlePrefix := prefixStyle.Render("Verifying your ")
-		a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render("...")
-		ts := t.S().TextInput
-		// make the blurred state be the same
-		ts.Blurred.Prompt = ts.Focused.Prompt
-		a.input.Prompt = a.spinner.View()
-		a.input.Blur()
-	case APIKeyInputStateVerified:
-		a.title = accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(" validated.")
-		ts := t.S().TextInput
-		// make the blurred state be the same
-		ts.Blurred.Prompt = ts.Focused.Prompt
-		a.input.SetStyles(ts)
-		a.input.Prompt = styles.CheckIcon + " "
-		a.input.Blur()
-	case APIKeyInputStateError:
-		a.title = errorStyle.Render("Invalid ") + accentStyle.Render(a.providerName+" API Key") + errorStyle.Render(". Try again?")
-		ts := t.S().TextInput
-		ts.Focused.Prompt = ts.Focused.Prompt.Foreground(t.Cherry)
-		a.input.Focus()
-		a.input.SetStyles(ts)
-		a.input.Prompt = styles.ErrorIcon + " "
-	}
-}
-
-func (a *APIKeyInput) View() string {
-	inputView := a.input.View()
-
-	dataPath := config.GlobalConfigData()
-	dataPath = home.Short(dataPath)
-	helpText := styles.CurrentTheme().S().Muted.
-		Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
-
-	var content string
-	if a.showTitle && a.title != "" {
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			a.title,
-			"",
-			inputView,
-			"",
-			helpText,
-		)
-	} else {
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			inputView,
-			"",
-			helpText,
-		)
-	}
-
-	return content
-}
-
-func (a *APIKeyInput) Cursor() *tea.Cursor {
-	cursor := a.input.Cursor()
-	if cursor != nil && a.showTitle {
-		cursor.Y += 2 // Adjust for title and spacing
-	}
-	return cursor
-}
-
-func (a *APIKeyInput) Value() string {
-	return a.input.Value()
-}
-
-func (a *APIKeyInput) Tick() tea.Cmd {
-	if a.state == APIKeyInputStateVerifying {
-		return a.spinner.Tick
-	}
-	return nil
-}
-
-func (a *APIKeyInput) SetWidth(width int) {
-	a.width = width
-	a.input.SetWidth(width - 4)
-}
-
-func (a *APIKeyInput) Reset() {
-	a.state = APIKeyInputStateInitial
-	a.input.SetValue("")
-	a.input.Focus()
-	a.updateStatePresentation()
-}

internal/tui/components/dialogs/models/keys.go 🔗

@@ -1,120 +0,0 @@
-package models
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	Select,
-	Next,
-	Previous,
-	Choose,
-	Tab,
-	Close key.Binding
-
-	isAPIKeyHelp  bool
-	isAPIKeyValid bool
-
-	isHyperDeviceFlow    bool
-	isCopilotDeviceFlow  bool
-	isCopilotUnavailable bool
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Select: key.NewBinding(
-			key.WithKeys("enter", "ctrl+y"),
-			key.WithHelp("enter", "choose"),
-		),
-		Next: key.NewBinding(
-			key.WithKeys("down", "ctrl+n"),
-			key.WithHelp("↓", "next item"),
-		),
-		Previous: key.NewBinding(
-			key.WithKeys("up", "ctrl+p"),
-			key.WithHelp("↑", "previous item"),
-		),
-		Choose: key.NewBinding(
-			key.WithKeys("left", "right", "h", "l"),
-			key.WithHelp("←→", "choose"),
-		),
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", "toggle type"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "exit"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Select,
-		k.Next,
-		k.Previous,
-		k.Tab,
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	if k.isHyperDeviceFlow || k.isCopilotDeviceFlow {
-		return []key.Binding{
-			key.NewBinding(
-				key.WithKeys("c"),
-				key.WithHelp("c", "copy code"),
-			),
-			key.NewBinding(
-				key.WithKeys("enter"),
-				key.WithHelp("enter", "copy & open"),
-			),
-			k.Close,
-		}
-	}
-	if k.isCopilotUnavailable {
-		return []key.Binding{
-			key.NewBinding(
-				key.WithKeys("enter"),
-				key.WithHelp("enter", "open signup"),
-			),
-			k.Close,
-		}
-	}
-	if k.isAPIKeyHelp && !k.isAPIKeyValid {
-		return []key.Binding{
-			key.NewBinding(
-				key.WithKeys("enter"),
-				key.WithHelp("enter", "submit"),
-			),
-			k.Close,
-		}
-	} else if k.isAPIKeyValid {
-		return []key.Binding{
-			k.Select,
-		}
-	}
-	return []key.Binding{
-		key.NewBinding(
-			key.WithKeys("down", "up"),
-			key.WithHelp("↑↓", "choose"),
-		),
-		k.Tab,
-		k.Select,
-		k.Close,
-	}
-}

internal/tui/components/dialogs/models/list.go 🔗

@@ -1,333 +0,0 @@
-package models
-
-import (
-	"cmp"
-	"fmt"
-	"slices"
-	"strings"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
-
-type ModelListComponent struct {
-	list      listModel
-	modelType int
-	providers []catwalk.Provider
-}
-
-func modelKey(providerID, modelID string) string {
-	if providerID == "" || modelID == "" {
-		return ""
-	}
-	return providerID + ":" + modelID
-}
-
-func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
-	t := styles.CurrentTheme()
-	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
-	options := []list.ListOption{
-		list.WithKeyMap(keyMap),
-		list.WithWrapNavigation(),
-	}
-	if shouldResize {
-		options = append(options, list.WithResizeByList())
-	}
-	modelList := list.NewFilterableGroupedList(
-		[]list.Group[list.CompletionItem[ModelOption]]{},
-		list.WithFilterInputStyle(inputStyle),
-		list.WithFilterPlaceholder(inputPlaceholder),
-		list.WithFilterListOptions(
-			options...,
-		),
-	)
-
-	return &ModelListComponent{
-		list:      modelList,
-		modelType: LargeModelType,
-	}
-}
-
-func (m *ModelListComponent) Init() tea.Cmd {
-	var cmds []tea.Cmd
-	if len(m.providers) == 0 {
-		cfg := config.Get()
-		providers, err := config.Providers(cfg)
-		filteredProviders := []catwalk.Provider{}
-		for _, p := range providers {
-			hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
-			isHyper := p.ID == "hyper"
-			isCopilot := p.ID == catwalk.InferenceProviderCopilot
-			if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot {
-				filteredProviders = append(filteredProviders, p)
-			}
-		}
-
-		m.providers = filteredProviders
-		if err != nil {
-			cmds = append(cmds, util.ReportError(err))
-		}
-	}
-	cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType))
-	return tea.Batch(cmds...)
-}
-
-func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
-	u, cmd := m.list.Update(msg)
-	m.list = u.(listModel)
-	return m, cmd
-}
-
-func (m *ModelListComponent) View() string {
-	return m.list.View()
-}
-
-func (m *ModelListComponent) Cursor() *tea.Cursor {
-	return m.list.Cursor()
-}
-
-func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
-	return m.list.SetSize(width, height)
-}
-
-func (m *ModelListComponent) SelectedModel() *ModelOption {
-	s := m.list.SelectedItem()
-	if s == nil {
-		return nil
-	}
-	sv := *s
-	model := sv.Value()
-	return &model
-}
-
-func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
-	t := styles.CurrentTheme()
-	m.modelType = modelType
-
-	var groups []list.Group[list.CompletionItem[ModelOption]]
-	// first none section
-	selectedItemID := ""
-	itemsByKey := make(map[string]list.CompletionItem[ModelOption])
-
-	cfg := config.Get()
-	var currentModel config.SelectedModel
-	selectedType := config.SelectedModelTypeLarge
-	if m.modelType == LargeModelType {
-		currentModel = cfg.Models[config.SelectedModelTypeLarge]
-		selectedType = config.SelectedModelTypeLarge
-	} else {
-		currentModel = cfg.Models[config.SelectedModelTypeSmall]
-		selectedType = config.SelectedModelTypeSmall
-	}
-	recentItems := cfg.RecentModels[selectedType]
-
-	configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon)
-	configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured"))
-
-	// Create a map to track which providers we've already added
-	addedProviders := make(map[string]bool)
-
-	// First, add any configured providers that are not in the known providers list
-	// These should appear at the top of the list
-	knownProviders, err := config.Providers(cfg)
-	if err != nil {
-		return util.ReportError(err)
-	}
-	for providerID, providerConfig := range cfg.Providers.Seq2() {
-		if providerConfig.Disable {
-			continue
-		}
-
-		// Check if this provider is not in the known providers list
-		if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) ||
-			!slices.ContainsFunc(m.providers, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) {
-			// Convert config provider to provider.Provider format
-			configProvider := providerConfig.ToProvider()
-
-			// Add this unknown provider to the list
-			name := configProvider.Name
-			if name == "" {
-				name = string(configProvider.ID)
-			}
-			section := list.NewItemSection(name)
-			section.SetInfo(configured)
-			group := list.Group[list.CompletionItem[ModelOption]]{
-				Section: section,
-			}
-			for _, model := range configProvider.Models {
-				modelOption := ModelOption{
-					Provider: configProvider,
-					Model:    model,
-				}
-				key := modelKey(string(configProvider.ID), model.ID)
-				item := list.NewCompletionItem(
-					model.Name,
-					modelOption,
-					list.WithCompletionID(key),
-				)
-				itemsByKey[key] = item
-
-				group.Items = append(group.Items, item)
-				if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
-					selectedItemID = item.ID()
-				}
-			}
-			groups = append(groups, group)
-
-			addedProviders[providerID] = true
-		}
-	}
-
-	// Move "Charm Hyper" to first position
-	// (but still after recent models and custom providers).
-	slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int {
-		switch {
-		case a.ID == "hyper":
-			return -1
-		case b.ID == "hyper":
-			return 1
-		default:
-			return 0
-		}
-	})
-
-	// Then add the known providers from the predefined list
-	for _, provider := range m.providers {
-		// Skip if we already added this provider as an unknown provider
-		if addedProviders[string(provider.ID)] {
-			continue
-		}
-
-		providerConfig, providerConfigured := cfg.Providers.Get(string(provider.ID))
-		if providerConfigured && providerConfig.Disable {
-			continue
-		}
-
-		displayProvider := provider
-		if providerConfigured {
-			displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
-			modelIndex := make(map[string]int, len(displayProvider.Models))
-			for i, model := range displayProvider.Models {
-				modelIndex[model.ID] = i
-			}
-			for _, model := range providerConfig.Models {
-				if model.ID == "" {
-					continue
-				}
-				if idx, ok := modelIndex[model.ID]; ok {
-					if model.Name != "" {
-						displayProvider.Models[idx].Name = model.Name
-					}
-					continue
-				}
-				if model.Name == "" {
-					model.Name = model.ID
-				}
-				displayProvider.Models = append(displayProvider.Models, model)
-				modelIndex[model.ID] = len(displayProvider.Models) - 1
-			}
-		}
-
-		name := displayProvider.Name
-		if name == "" {
-			name = string(displayProvider.ID)
-		}
-
-		section := list.NewItemSection(name)
-		if providerConfigured {
-			section.SetInfo(configured)
-		}
-		group := list.Group[list.CompletionItem[ModelOption]]{
-			Section: section,
-		}
-		for _, model := range displayProvider.Models {
-			modelOption := ModelOption{
-				Provider: displayProvider,
-				Model:    model,
-			}
-			key := modelKey(string(displayProvider.ID), model.ID)
-			item := list.NewCompletionItem(
-				model.Name,
-				modelOption,
-				list.WithCompletionID(key),
-			)
-			itemsByKey[key] = item
-			group.Items = append(group.Items, item)
-			if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider {
-				selectedItemID = item.ID()
-			}
-		}
-		groups = append(groups, group)
-	}
-
-	if len(recentItems) > 0 {
-		recentSection := list.NewItemSection("Recently used")
-		recentGroup := list.Group[list.CompletionItem[ModelOption]]{
-			Section: recentSection,
-		}
-		var validRecentItems []config.SelectedModel
-		for _, recent := range recentItems {
-			key := modelKey(recent.Provider, recent.Model)
-			option, ok := itemsByKey[key]
-			if !ok {
-				continue
-			}
-			validRecentItems = append(validRecentItems, recent)
-			recentID := fmt.Sprintf("recent::%s", key)
-			modelOption := option.Value()
-			providerName := modelOption.Provider.Name
-			if providerName == "" {
-				providerName = string(modelOption.Provider.ID)
-			}
-			item := list.NewCompletionItem(
-				modelOption.Model.Name,
-				option.Value(),
-				list.WithCompletionID(recentID),
-				list.WithCompletionShortcut(providerName),
-			)
-			recentGroup.Items = append(recentGroup.Items, item)
-			if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
-				selectedItemID = recentID
-			}
-		}
-
-		if len(validRecentItems) != len(recentItems) {
-			if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
-				return util.ReportError(err)
-			}
-		}
-
-		if len(recentGroup.Items) > 0 {
-			groups = append([]list.Group[list.CompletionItem[ModelOption]]{recentGroup}, groups...)
-		}
-	}
-
-	var cmds []tea.Cmd
-
-	cmd := m.list.SetGroups(groups)
-
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-	cmd = m.list.SetSelected(selectedItemID)
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-
-	return tea.Sequence(cmds...)
-}
-
-// GetModelType returns the current model type
-func (m *ModelListComponent) GetModelType() int {
-	return m.modelType
-}
-
-func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
-	m.list.SetInputPlaceholder(placeholder)
-}

internal/tui/components/dialogs/models/list_recent_test.go 🔗

@@ -1,369 +0,0 @@
-package models
-
-import (
-	"encoding/json"
-	"io/fs"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/stretchr/testify/require"
-)
-
-// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop.
-func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) {
-	t.Helper()
-	for cmd != nil {
-		msg := cmd()
-		var next tea.Cmd
-		_, next = m.Update(msg)
-		cmd = next
-	}
-}
-
-// readConfigJSON reads and unmarshals the JSON config file at path.
-func readConfigJSON(t *testing.T, path string) map[string]any {
-	t.Helper()
-	baseDir := filepath.Dir(path)
-	fileName := filepath.Base(path)
-	b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
-	require.NoError(t, err)
-	var out map[string]any
-	require.NoError(t, json.Unmarshal(b, &out))
-	return out
-}
-
-// readRecentModels reads the recent_models section from the config file.
-func readRecentModels(t *testing.T, path string) map[string]any {
-	t.Helper()
-	out := readConfigJSON(t, path)
-	rm, ok := out["recent_models"].(map[string]any)
-	require.True(t, ok)
-	return rm
-}
-
-func TestModelList_RecentlyUsedSectionAndPrunesInvalid(t *testing.T) {
-	// Pre-initialize logger to os.DevNull to prevent file lock on Windows.
-	log.Setup(os.DevNull, false)
-
-	// Isolate config/data paths
-	cfgDir := t.TempDir()
-	dataDir := t.TempDir()
-	t.Setenv("XDG_CONFIG_HOME", cfgDir)
-	t.Setenv("XDG_DATA_HOME", dataDir)
-
-	// Pre-seed config so provider auto-update is disabled and we have recents
-	confPath := filepath.Join(cfgDir, "crush", "crush.json")
-	require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
-	initial := map[string]any{
-		"options": map[string]any{
-			"disable_provider_auto_update": true,
-		},
-		"models": map[string]any{
-			"large": map[string]any{
-				"model":    "m1",
-				"provider": "p1",
-			},
-		},
-		"recent_models": map[string]any{
-			"large": []any{
-				map[string]any{"model": "m2", "provider": "p1"},              // valid
-				map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned
-			},
-		},
-	}
-	bts, err := json.Marshal(initial)
-	require.NoError(t, err)
-	require.NoError(t, os.WriteFile(confPath, bts, 0o644))
-
-	// Also create empty providers.json to prevent loading real providers
-	dataConfDir := filepath.Join(dataDir, "crush")
-	require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
-	emptyProviders := []byte("[]")
-	require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
-
-	// Initialize global config instance (no network due to auto-update disabled)
-	_, err = config.Init(cfgDir, dataDir, false)
-	require.NoError(t, err)
-
-	// Build a small provider set for the list component
-	provider := catwalk.Provider{
-		ID:   catwalk.InferenceProvider("p1"),
-		Name: "Provider One",
-		Models: []catwalk.Model{
-			{ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
-			{ID: "m2", Name: "Model Two", DefaultMaxTokens: 100}, // recent
-		},
-	}
-
-	// Create and initialize the component with our provider set
-	listKeyMap := list.DefaultKeyMap()
-	cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
-	cmp.providers = []catwalk.Provider{provider}
-	execCmdML(t, cmp, cmp.Init())
-
-	// Find all recent items (IDs prefixed with "recent::") and verify pruning
-	groups := cmp.list.Groups()
-	require.NotEmpty(t, groups)
-	var recentItems []list.CompletionItem[ModelOption]
-	for _, g := range groups {
-		for _, it := range g.Items {
-			if strings.HasPrefix(it.ID(), "recent::") {
-				recentItems = append(recentItems, it)
-			}
-		}
-	}
-	require.NotEmpty(t, recentItems, "no recent items found")
-	// Ensure the valid recent (p1:m2) is present and the invalid one is not
-	foundValid := false
-	for _, it := range recentItems {
-		if it.ID() == "recent::p1:m2" {
-			foundValid = true
-		}
-		require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned")
-	}
-	require.True(t, foundValid, "expected valid recent not found")
-
-	// Verify original config in cfgDir remains unchanged
-	origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
-	afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
-	require.NoError(t, err)
-	var origParsed map[string]any
-	require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
-	origRM := origParsed["recent_models"].(map[string]any)
-	origLarge := origRM["large"].([]any)
-	require.Len(t, origLarge, 2, "original config should be unchanged")
-
-	// Config should be rewritten with pruned recents in dataDir
-	dataConf := filepath.Join(dataDir, "crush", "crush.json")
-	rm := readRecentModels(t, dataConf)
-	largeAny, ok := rm["large"].([]any)
-	require.True(t, ok)
-	// Ensure that only valid recent(s) remain and the invalid one is removed
-	found := false
-	for _, v := range largeAny {
-		m := v.(map[string]any)
-		require.NotEqual(t, "unknown-provider", m["provider"], "invalid provider should be pruned")
-		if m["provider"] == "p1" && m["model"] == "m2" {
-			found = true
-		}
-	}
-	require.True(t, found, "persisted recents should include p1:m2")
-}
-
-func TestModelList_PrunesInvalidModelWithinValidProvider(t *testing.T) {
-	// Pre-initialize logger to os.DevNull to prevent file lock on Windows.
-	log.Setup(os.DevNull, false)
-
-	// Isolate config/data paths
-	cfgDir := t.TempDir()
-	dataDir := t.TempDir()
-	t.Setenv("XDG_CONFIG_HOME", cfgDir)
-	t.Setenv("XDG_DATA_HOME", dataDir)
-
-	// Pre-seed config with valid provider but one invalid model
-	confPath := filepath.Join(cfgDir, "crush", "crush.json")
-	require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
-	initial := map[string]any{
-		"options": map[string]any{
-			"disable_provider_auto_update": true,
-		},
-		"models": map[string]any{
-			"large": map[string]any{
-				"model":    "m1",
-				"provider": "p1",
-			},
-		},
-		"recent_models": map[string]any{
-			"large": []any{
-				map[string]any{"model": "m1", "provider": "p1"},      // valid
-				map[string]any{"model": "missing", "provider": "p1"}, // invalid model
-			},
-		},
-	}
-	bts, err := json.Marshal(initial)
-	require.NoError(t, err)
-	require.NoError(t, os.WriteFile(confPath, bts, 0o644))
-
-	// Create empty providers.json
-	dataConfDir := filepath.Join(dataDir, "crush")
-	require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
-	emptyProviders := []byte("[]")
-	require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
-
-	// Initialize global config instance
-	_, err = config.Init(cfgDir, dataDir, false)
-	require.NoError(t, err)
-
-	// Build provider set that only includes m1, not "missing"
-	provider := catwalk.Provider{
-		ID:   catwalk.InferenceProvider("p1"),
-		Name: "Provider One",
-		Models: []catwalk.Model{
-			{ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
-		},
-	}
-
-	// Create and initialize component
-	listKeyMap := list.DefaultKeyMap()
-	cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
-	cmp.providers = []catwalk.Provider{provider}
-	execCmdML(t, cmp, cmp.Init())
-
-	// Find all recent items
-	groups := cmp.list.Groups()
-	require.NotEmpty(t, groups)
-	var recentItems []list.CompletionItem[ModelOption]
-	for _, g := range groups {
-		for _, it := range g.Items {
-			if strings.HasPrefix(it.ID(), "recent::") {
-				recentItems = append(recentItems, it)
-			}
-		}
-	}
-	require.NotEmpty(t, recentItems, "valid recent should exist")
-
-	// Verify the valid recent is present and invalid model is not
-	foundValid := false
-	for _, it := range recentItems {
-		if it.ID() == "recent::p1:m1" {
-			foundValid = true
-		}
-		require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned")
-	}
-	require.True(t, foundValid, "valid recent p1:m1 should be present")
-
-	// Verify original config in cfgDir remains unchanged
-	origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
-	afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
-	require.NoError(t, err)
-	var origParsed map[string]any
-	require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
-	origRM := origParsed["recent_models"].(map[string]any)
-	origLarge := origRM["large"].([]any)
-	require.Len(t, origLarge, 2, "original config should be unchanged")
-
-	// Config should be rewritten with pruned recents in dataDir
-	dataConf := filepath.Join(dataDir, "crush", "crush.json")
-	rm := readRecentModels(t, dataConf)
-	largeAny, ok := rm["large"].([]any)
-	require.True(t, ok)
-	require.Len(t, largeAny, 1, "should only have one valid model")
-	// Verify only p1:m1 remains
-	m := largeAny[0].(map[string]any)
-	require.Equal(t, "p1", m["provider"])
-	require.Equal(t, "m1", m["model"])
-}
-
-func TestModelKey_EmptyInputs(t *testing.T) {
-	// Empty provider
-	require.Equal(t, "", modelKey("", "model"))
-	// Empty model
-	require.Equal(t, "", modelKey("provider", ""))
-	// Both empty
-	require.Equal(t, "", modelKey("", ""))
-	// Valid inputs
-	require.Equal(t, "p:m", modelKey("p", "m"))
-}
-
-func TestModelList_AllRecentsInvalid(t *testing.T) {
-	// Pre-initialize logger to os.DevNull to prevent file lock on Windows.
-	log.Setup(os.DevNull, false)
-
-	// Isolate config/data paths
-	cfgDir := t.TempDir()
-	dataDir := t.TempDir()
-	t.Setenv("XDG_CONFIG_HOME", cfgDir)
-	t.Setenv("XDG_DATA_HOME", dataDir)
-
-	// Pre-seed config with only invalid recents
-	confPath := filepath.Join(cfgDir, "crush", "crush.json")
-	require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
-	initial := map[string]any{
-		"options": map[string]any{
-			"disable_provider_auto_update": true,
-		},
-		"models": map[string]any{
-			"large": map[string]any{
-				"model":    "m1",
-				"provider": "p1",
-			},
-		},
-		"recent_models": map[string]any{
-			"large": []any{
-				map[string]any{"model": "x", "provider": "unknown1"},
-				map[string]any{"model": "y", "provider": "unknown2"},
-			},
-		},
-	}
-	bts, err := json.Marshal(initial)
-	require.NoError(t, err)
-	require.NoError(t, os.WriteFile(confPath, bts, 0o644))
-
-	// Also create empty providers.json and data config
-	dataConfDir := filepath.Join(dataDir, "crush")
-	require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
-	emptyProviders := []byte("[]")
-	require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
-
-	// Initialize global config instance with isolated dataDir
-	_, err = config.Init(cfgDir, dataDir, false)
-	require.NoError(t, err)
-
-	// Build provider set (doesn't include unknown1 or unknown2)
-	provider := catwalk.Provider{
-		ID:   catwalk.InferenceProvider("p1"),
-		Name: "Provider One",
-		Models: []catwalk.Model{
-			{ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
-		},
-	}
-
-	// Create and initialize component
-	listKeyMap := list.DefaultKeyMap()
-	cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
-	cmp.providers = []catwalk.Provider{provider}
-	execCmdML(t, cmp, cmp.Init())
-
-	// Verify no recent items exist in UI
-	groups := cmp.list.Groups()
-	require.NotEmpty(t, groups)
-	var recentItems []list.CompletionItem[ModelOption]
-	for _, g := range groups {
-		for _, it := range g.Items {
-			if strings.HasPrefix(it.ID(), "recent::") {
-				recentItems = append(recentItems, it)
-			}
-		}
-	}
-	require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section")
-
-	// Verify original config in cfgDir remains unchanged
-	origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
-	afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
-	require.NoError(t, err)
-	var origParsed map[string]any
-	require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
-	origRM := origParsed["recent_models"].(map[string]any)
-	origLarge := origRM["large"].([]any)
-	require.Len(t, origLarge, 2, "original config should be unchanged")
-
-	// Config should be rewritten with empty recents in dataDir
-	dataConf := filepath.Join(dataDir, "crush", "crush.json")
-	rm := readRecentModels(t, dataConf)
-	// When all recents are pruned, the value may be nil or an empty array
-	largeVal := rm["large"]
-	if largeVal == nil {
-		// nil is acceptable - means empty
-		return
-	}
-	largeAny, ok := largeVal.([]any)
-	require.True(t, ok, "large key should be nil or array")
-	require.Empty(t, largeAny, "persisted recents should be empty after pruning all invalid entries")
-}

internal/tui/components/dialogs/models/models.go 🔗

@@ -1,549 +0,0 @@
-// Package models provides the model selection dialog for the TUI.
-package models
-
-import (
-	"fmt"
-	"time"
-
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/spinner"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/catwalk/pkg/catwalk"
-	"charm.land/lipgloss/v2"
-	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
-	ModelsDialogID dialogs.DialogID = "models"
-
-	defaultWidth = 60
-)
-
-const (
-	LargeModelType int = iota
-	SmallModelType
-
-	largeModelInputPlaceholder = "Choose a model for large, complex tasks"
-	smallModelInputPlaceholder = "Choose a model for small, simple tasks"
-)
-
-// ModelSelectedMsg is sent when a model is selected
-type ModelSelectedMsg struct {
-	Model     config.SelectedModel
-	ModelType config.SelectedModelType
-}
-
-// CloseModelDialogMsg is sent when a model is selected
-type CloseModelDialogMsg struct{}
-
-// ModelDialog interface for the model selection dialog
-type ModelDialog interface {
-	dialogs.DialogModel
-}
-
-type ModelOption struct {
-	Provider catwalk.Provider
-	Model    catwalk.Model
-}
-
-type modelDialogCmp struct {
-	width   int
-	wWidth  int
-	wHeight int
-
-	modelList *ModelListComponent
-	keyMap    KeyMap
-	help      help.Model
-
-	// API key state
-	needsAPIKey       bool
-	apiKeyInput       *APIKeyInput
-	selectedModel     *ModelOption
-	selectedModelType config.SelectedModelType
-	isAPIKeyValid     bool
-	apiKeyValue       string
-
-	// Hyper device flow state
-	hyperDeviceFlow     *hyper.DeviceFlow
-	showHyperDeviceFlow bool
-
-	// Copilot device flow state
-	copilotDeviceFlow     *copilot.DeviceFlow
-	showCopilotDeviceFlow bool
-}
-
-func NewModelDialogCmp() ModelDialog {
-	keyMap := DefaultKeyMap()
-
-	listKeyMap := list.DefaultKeyMap()
-	listKeyMap.Down.SetEnabled(false)
-	listKeyMap.Up.SetEnabled(false)
-	listKeyMap.DownOneItem = keyMap.Next
-	listKeyMap.UpOneItem = keyMap.Previous
-
-	t := styles.CurrentTheme()
-	modelList := NewModelListComponent(listKeyMap, largeModelInputPlaceholder, true)
-	apiKeyInput := NewAPIKeyInput()
-	apiKeyInput.SetShowTitle(false)
-	help := help.New()
-	help.Styles = t.S().Help
-
-	return &modelDialogCmp{
-		modelList:   modelList,
-		apiKeyInput: apiKeyInput,
-		width:       defaultWidth,
-		keyMap:      DefaultKeyMap(),
-		help:        help,
-	}
-}
-
-func (m *modelDialogCmp) Init() tea.Cmd {
-	return tea.Batch(
-		m.modelList.Init(),
-		m.apiKeyInput.Init(),
-	)
-}
-
-func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		m.wWidth = msg.Width
-		m.wHeight = msg.Height
-		m.apiKeyInput.SetWidth(m.width - 2)
-		m.help.SetWidth(m.width - 2)
-		return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
-	case APIKeyStateChangeMsg:
-		u, cmd := m.apiKeyInput.Update(msg)
-		m.apiKeyInput = u.(*APIKeyInput)
-		return m, cmd
-	case hyper.DeviceFlowCompletedMsg:
-		return m, m.saveOauthTokenAndContinue(msg.Token, true)
-	case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
-		if m.hyperDeviceFlow != nil {
-			u, cmd := m.hyperDeviceFlow.Update(msg)
-			m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
-			return m, cmd
-		}
-		return m, nil
-	case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
-		if m.copilotDeviceFlow != nil {
-			u, cmd := m.copilotDeviceFlow.Update(msg)
-			m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
-			return m, cmd
-		}
-		return m, nil
-	case copilot.DeviceFlowCompletedMsg:
-		return m, m.saveOauthTokenAndContinue(msg.Token, true)
-	case tea.KeyPressMsg:
-		switch {
-		// Handle Hyper device flow keys
-		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow:
-			return m, m.hyperDeviceFlow.CopyCode()
-		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
-			return m, m.copilotDeviceFlow.CopyCode()
-		case key.Matches(msg, m.keyMap.Select):
-			// If showing device flow, enter copies code and opens URL
-			if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
-				return m, m.hyperDeviceFlow.CopyCodeAndOpenURL()
-			}
-			if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
-				return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
-			}
-			selectedItem := m.modelList.SelectedModel()
-			if selectedItem == nil {
-				return m, nil
-			}
-
-			modelType := config.SelectedModelTypeLarge
-			if m.modelList.GetModelType() == SmallModelType {
-				modelType = config.SelectedModelTypeSmall
-			}
-
-			askForApiKey := func() {
-				m.keyMap.isAPIKeyHelp = true
-				m.showHyperDeviceFlow = false
-				m.showCopilotDeviceFlow = false
-				m.needsAPIKey = true
-				m.selectedModel = selectedItem
-				m.selectedModelType = modelType
-				m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
-			}
-
-			if m.isAPIKeyValid {
-				return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
-			}
-			if m.needsAPIKey {
-				// Handle API key submission
-				m.apiKeyValue = m.apiKeyInput.Value()
-				provider, err := m.getProvider(m.selectedModel.Provider.ID)
-				if err != nil || provider == nil {
-					return m, util.ReportError(fmt.Errorf("provider %s not found", m.selectedModel.Provider.ID))
-				}
-				providerConfig := config.ProviderConfig{
-					ID:      string(m.selectedModel.Provider.ID),
-					Name:    m.selectedModel.Provider.Name,
-					APIKey:  m.apiKeyValue,
-					Type:    provider.Type,
-					BaseURL: provider.APIEndpoint,
-				}
-				return m, tea.Sequence(
-					util.CmdHandler(APIKeyStateChangeMsg{
-						State: APIKeyInputStateVerifying,
-					}),
-					func() tea.Msg {
-						start := time.Now()
-						err := providerConfig.TestConnection(config.Get().Resolver())
-						// intentionally wait for at least 750ms to make sure the user sees the spinner
-						elapsed := time.Since(start)
-						if elapsed < 750*time.Millisecond {
-							time.Sleep(750*time.Millisecond - elapsed)
-						}
-						if err == nil {
-							m.isAPIKeyValid = true
-							return APIKeyStateChangeMsg{
-								State: APIKeyInputStateVerified,
-							}
-						}
-						return APIKeyStateChangeMsg{
-							State: APIKeyInputStateError,
-						}
-					},
-				)
-			}
-
-			// Check if provider is configured
-			if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
-				return m, tea.Sequence(
-					util.CmdHandler(dialogs.CloseDialogMsg{}),
-					util.CmdHandler(ModelSelectedMsg{
-						Model: config.SelectedModel{
-							Model:           selectedItem.Model.ID,
-							Provider:        string(selectedItem.Provider.ID),
-							ReasoningEffort: selectedItem.Model.DefaultReasoningEffort,
-							MaxTokens:       selectedItem.Model.DefaultMaxTokens,
-						},
-						ModelType: modelType,
-					}),
-				)
-			}
-			switch selectedItem.Provider.ID {
-			case hyperp.Name:
-				m.showHyperDeviceFlow = true
-				m.selectedModel = selectedItem
-				m.selectedModelType = modelType
-				m.hyperDeviceFlow = hyper.NewDeviceFlow()
-				m.hyperDeviceFlow.SetWidth(m.width - 2)
-				return m, m.hyperDeviceFlow.Init()
-			case catwalk.InferenceProviderCopilot:
-				if token, ok := config.Get().ImportCopilot(); ok {
-					m.selectedModel = selectedItem
-					m.selectedModelType = modelType
-					return m, m.saveOauthTokenAndContinue(token, true)
-				}
-				m.showCopilotDeviceFlow = true
-				m.selectedModel = selectedItem
-				m.selectedModelType = modelType
-				m.copilotDeviceFlow = copilot.NewDeviceFlow()
-				m.copilotDeviceFlow.SetWidth(m.width - 2)
-				return m, m.copilotDeviceFlow.Init()
-			}
-			// For other providers, show API key input
-			askForApiKey()
-			return m, nil
-		case key.Matches(msg, m.keyMap.Tab):
-			switch {
-			case m.needsAPIKey:
-				u, cmd := m.apiKeyInput.Update(msg)
-				m.apiKeyInput = u.(*APIKeyInput)
-				return m, cmd
-			case m.modelList.GetModelType() == LargeModelType:
-				m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
-				return m, m.modelList.SetModelType(SmallModelType)
-			default:
-				m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
-				return m, m.modelList.SetModelType(LargeModelType)
-			}
-		case key.Matches(msg, m.keyMap.Close):
-			switch {
-			case m.showHyperDeviceFlow:
-				if m.hyperDeviceFlow != nil {
-					m.hyperDeviceFlow.Cancel()
-				}
-				m.showHyperDeviceFlow = false
-				m.selectedModel = nil
-			case m.showCopilotDeviceFlow:
-				if m.copilotDeviceFlow != nil {
-					m.copilotDeviceFlow.Cancel()
-				}
-				m.showCopilotDeviceFlow = false
-				m.selectedModel = nil
-			case m.needsAPIKey:
-				if m.isAPIKeyValid {
-					return m, nil
-				}
-				// Go back to model selection
-				m.needsAPIKey = false
-				m.selectedModel = nil
-				m.isAPIKeyValid = false
-				m.apiKeyValue = ""
-				m.apiKeyInput.Reset()
-				return m, nil
-			default:
-				return m, util.CmdHandler(dialogs.CloseDialogMsg{})
-			}
-		default:
-			switch {
-			case m.needsAPIKey:
-				u, cmd := m.apiKeyInput.Update(msg)
-				m.apiKeyInput = u.(*APIKeyInput)
-				return m, cmd
-			default:
-				u, cmd := m.modelList.Update(msg)
-				m.modelList = u
-				return m, cmd
-			}
-		}
-	case tea.PasteMsg:
-		switch {
-		case m.needsAPIKey:
-			u, cmd := m.apiKeyInput.Update(msg)
-			m.apiKeyInput = u.(*APIKeyInput)
-			return m, cmd
-		default:
-			var cmd tea.Cmd
-			m.modelList, cmd = m.modelList.Update(msg)
-			return m, cmd
-		}
-	case spinner.TickMsg:
-		u, cmd := m.apiKeyInput.Update(msg)
-		m.apiKeyInput = u.(*APIKeyInput)
-		if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
-			u, cmd = m.hyperDeviceFlow.Update(msg)
-			m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
-		}
-		if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
-			u, cmd = m.copilotDeviceFlow.Update(msg)
-			m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
-		}
-		return m, cmd
-	default:
-		// Pass all other messages to the device flow for spinner animation
-		switch {
-		case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil:
-			u, cmd := m.hyperDeviceFlow.Update(msg)
-			m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
-			return m, cmd
-		case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil:
-			u, cmd := m.copilotDeviceFlow.Update(msg)
-			m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
-			return m, cmd
-		default:
-			u, cmd := m.apiKeyInput.Update(msg)
-			m.apiKeyInput = u.(*APIKeyInput)
-			return m, cmd
-		}
-	}
-	return m, nil
-}
-
-func (m *modelDialogCmp) View() string {
-	t := styles.CurrentTheme()
-
-	if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
-		// Show Hyper device flow
-		m.keyMap.isHyperDeviceFlow = true
-		deviceFlowView := m.hyperDeviceFlow.View()
-		content := lipgloss.JoinVertical(
-			lipgloss.Left,
-			t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with Hyper", m.width-4)),
-			deviceFlowView,
-			"",
-			t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
-		)
-		return m.style().Render(content)
-	}
-	if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
-		// Show Hyper device flow
-		m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable
-		m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable
-		deviceFlowView := m.copilotDeviceFlow.View()
-		content := lipgloss.JoinVertical(
-			lipgloss.Left,
-			t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)),
-			deviceFlowView,
-			"",
-			t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
-		)
-		return m.style().Render(content)
-	}
-
-	// Reset the flags when not showing device flow
-	m.keyMap.isHyperDeviceFlow = false
-	m.keyMap.isCopilotDeviceFlow = false
-	m.keyMap.isCopilotUnavailable = false
-
-	switch {
-	case m.needsAPIKey:
-		// Show API key input
-		m.keyMap.isAPIKeyHelp = true
-		m.keyMap.isAPIKeyValid = m.isAPIKeyValid
-		apiKeyView := m.apiKeyInput.View()
-		apiKeyView = t.S().Base.Width(m.width - 3).Height(lipgloss.Height(apiKeyView)).PaddingLeft(1).Render(apiKeyView)
-		content := lipgloss.JoinVertical(
-			lipgloss.Left,
-			t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(m.apiKeyInput.GetTitle(), m.width-4)),
-			apiKeyView,
-			"",
-			t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
-		)
-		return m.style().Render(content)
-	}
-
-	// Show model selection
-	listView := m.modelList.View()
-	radio := m.modelTypeRadio()
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-lipgloss.Width(radio)-5)+" "+radio),
-		listView,
-		"",
-		t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
-	)
-	return m.style().Render(content)
-}
-
-func (m *modelDialogCmp) Cursor() *tea.Cursor {
-	if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
-		return m.hyperDeviceFlow.Cursor()
-	}
-	if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
-		return m.copilotDeviceFlow.Cursor()
-	}
-	if m.needsAPIKey {
-		cursor := m.apiKeyInput.Cursor()
-		if cursor != nil {
-			cursor = m.moveCursor(cursor)
-			return cursor
-		}
-	} else {
-		cursor := m.modelList.Cursor()
-		if cursor != nil {
-			cursor = m.moveCursor(cursor)
-			return cursor
-		}
-	}
-	return nil
-}
-
-func (m *modelDialogCmp) style() lipgloss.Style {
-	t := styles.CurrentTheme()
-	return t.S().Base.
-		Width(m.width).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus)
-}
-
-func (m *modelDialogCmp) listWidth() int {
-	return m.width - 2
-}
-
-func (m *modelDialogCmp) listHeight() int {
-	return m.wHeight / 2
-}
-
-func (m *modelDialogCmp) Position() (int, int) {
-	row := m.wHeight/4 - 2 // just a bit above the center
-	col := m.wWidth / 2
-	col -= m.width / 2
-	return row, col
-}
-
-func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
-	row, col := m.Position()
-	if m.needsAPIKey {
-		offset := row + 3 // Border + title + API key input offset
-		cursor.Y += offset
-		cursor.X = cursor.X + col + 2
-	} else {
-		offset := row + 3 // Border + title
-		cursor.Y += offset
-		cursor.X = cursor.X + col + 2
-	}
-	return cursor
-}
-
-func (m *modelDialogCmp) ID() dialogs.DialogID {
-	return ModelsDialogID
-}
-
-func (m *modelDialogCmp) modelTypeRadio() string {
-	t := styles.CurrentTheme()
-	choices := []string{"Large Task", "Small Task"}
-	iconSelected := "◉"
-	iconUnselected := "○"
-	if m.modelList.GetModelType() == LargeModelType {
-		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + "  " + iconUnselected + " " + choices[1])
-	}
-	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + "  " + iconSelected + " " + choices[1])
-}
-
-func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
-	cfg := config.Get()
-	_, ok := cfg.Providers.Get(providerID)
-	return ok
-}
-
-func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
-	cfg := config.Get()
-	providers, err := config.Providers(cfg)
-	if err != nil {
-		return nil, err
-	}
-	for _, p := range providers {
-		if p.ID == providerID {
-			return &p, nil
-		}
-	}
-	return nil, nil
-}
-
-func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd {
-	if m.selectedModel == nil {
-		return util.ReportError(fmt.Errorf("no model selected"))
-	}
-
-	cfg := config.Get()
-	err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey)
-	if err != nil {
-		return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
-	}
-
-	// Reset API key state and continue with model selection
-	selectedModel := *m.selectedModel
-	var cmds []tea.Cmd
-	if close {
-		cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
-	}
-	cmds = append(
-		cmds,
-		util.CmdHandler(ModelSelectedMsg{
-			Model: config.SelectedModel{
-				Model:           selectedModel.Model.ID,
-				Provider:        string(selectedModel.Provider.ID),
-				ReasoningEffort: selectedModel.Model.DefaultReasoningEffort,
-				MaxTokens:       selectedModel.Model.DefaultMaxTokens,
-			},
-			ModelType: m.selectedModelType,
-		}),
-	)
-	return tea.Sequence(cmds...)
-}

internal/tui/components/dialogs/permissions/keys.go 🔗

@@ -1,113 +0,0 @@
-package permissions
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	Left,
-	Right,
-	Tab,
-	Select,
-	Allow,
-	AllowSession,
-	Deny,
-	ToggleDiffMode,
-	ScrollDown,
-	ScrollUp key.Binding
-	ScrollLeft,
-	ScrollRight key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Left: key.NewBinding(
-			key.WithKeys("left", "h"),
-			key.WithHelp("←", "previous"),
-		),
-		Right: key.NewBinding(
-			key.WithKeys("right", "l"),
-			key.WithHelp("→", "next"),
-		),
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", "switch"),
-		),
-		Allow: key.NewBinding(
-			key.WithKeys("a", "A", "ctrl+a"),
-			key.WithHelp("a", "allow"),
-		),
-		AllowSession: key.NewBinding(
-			key.WithKeys("s", "S", "ctrl+s"),
-			key.WithHelp("s", "allow session"),
-		),
-		Deny: key.NewBinding(
-			key.WithKeys("d", "D", "esc"),
-			key.WithHelp("d", "deny"),
-		),
-		Select: key.NewBinding(
-			key.WithKeys("enter", "ctrl+y"),
-			key.WithHelp("enter", "confirm"),
-		),
-		ToggleDiffMode: key.NewBinding(
-			key.WithKeys("t"),
-			key.WithHelp("t", "toggle diff mode"),
-		),
-		ScrollDown: key.NewBinding(
-			key.WithKeys("shift+down", "J"),
-			key.WithHelp("shift+↓", "scroll down"),
-		),
-		ScrollUp: key.NewBinding(
-			key.WithKeys("shift+up", "K"),
-			key.WithHelp("shift+↑", "scroll up"),
-		),
-		ScrollLeft: key.NewBinding(
-			key.WithKeys("shift+left", "H"),
-			key.WithHelp("shift+←", "scroll left"),
-		),
-		ScrollRight: key.NewBinding(
-			key.WithKeys("shift+right", "L"),
-			key.WithHelp("shift+→", "scroll right"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Left,
-		k.Right,
-		k.Tab,
-		k.Select,
-		k.Allow,
-		k.AllowSession,
-		k.Deny,
-		k.ToggleDiffMode,
-		k.ScrollDown,
-		k.ScrollUp,
-		k.ScrollLeft,
-		k.ScrollRight,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.ToggleDiffMode,
-		key.NewBinding(
-			key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
-			key.WithHelp("shift+←↓↑→", "scroll"),
-		),
-	}
-}

internal/tui/components/dialogs/permissions/permissions.go 🔗

@@ -1,899 +0,0 @@
-package permissions
-
-import (
-	"encoding/json"
-	"fmt"
-	"strings"
-
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/viewport"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/agent/tools"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/x/ansi"
-)
-
-type PermissionAction string
-
-// Permission responses
-const (
-	PermissionAllow           PermissionAction = "allow"
-	PermissionAllowForSession PermissionAction = "allow_session"
-	PermissionDeny            PermissionAction = "deny"
-
-	PermissionsDialogID dialogs.DialogID = "permissions"
-)
-
-// PermissionResponseMsg represents the user's response to a permission request
-type PermissionResponseMsg struct {
-	Permission permission.PermissionRequest
-	Action     PermissionAction
-}
-
-// PermissionDialogCmp interface for permission dialog component
-type PermissionDialogCmp interface {
-	dialogs.DialogModel
-}
-
-// permissionDialogCmp is the implementation of PermissionDialog
-type permissionDialogCmp struct {
-	wWidth          int
-	wHeight         int
-	width           int
-	height          int
-	permission      permission.PermissionRequest
-	contentViewPort viewport.Model
-	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
-
-	// Diff view state
-	defaultDiffSplitMode bool  // true for split, false for unified
-	diffSplitMode        *bool // nil means use defaultDiffSplitMode
-	diffXOffset          int   // horizontal scroll offset
-	diffYOffset          int   // vertical scroll offset
-
-	// Caching
-	cachedContent string
-	contentDirty  bool
-
-	positionRow int // Row position for dialog
-	positionCol int // Column position for dialog
-
-	finalDialogHeight int
-
-	keyMap KeyMap
-}
-
-func NewPermissionDialogCmp(permission permission.PermissionRequest, opts *Options) PermissionDialogCmp {
-	if opts == nil {
-		opts = &Options{}
-	}
-
-	// Create viewport for content
-	contentViewport := viewport.New()
-	return &permissionDialogCmp{
-		contentViewPort: contentViewport,
-		selectedOption:  0, // Default to "Allow"
-		permission:      permission,
-		diffSplitMode:   opts.isSplitMode(),
-		keyMap:          DefaultKeyMap(),
-		contentDirty:    true, // Mark as dirty initially
-	}
-}
-
-func (p *permissionDialogCmp) Init() tea.Cmd {
-	return p.contentViewPort.Init()
-}
-
-func (p *permissionDialogCmp) supportsDiffView() bool {
-	return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
-}
-
-func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		p.wWidth = msg.Width
-		p.wHeight = msg.Height
-		p.contentDirty = true // Mark content as dirty on window resize
-		cmd := p.SetSize()
-		cmds = append(cmds, cmd)
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
-			p.selectedOption = (p.selectedOption + 1) % 3
-			return p, nil
-		case key.Matches(msg, p.keyMap.Left):
-			p.selectedOption = (p.selectedOption + 2) % 3
-		case key.Matches(msg, p.keyMap.Select):
-			return p, p.selectCurrentOption()
-		case key.Matches(msg, p.keyMap.Allow):
-			return p, tea.Batch(
-				util.CmdHandler(dialogs.CloseDialogMsg{}),
-				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
-			)
-		case key.Matches(msg, p.keyMap.AllowSession):
-			return p, tea.Batch(
-				util.CmdHandler(dialogs.CloseDialogMsg{}),
-				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
-			)
-		case key.Matches(msg, p.keyMap.Deny):
-			return p, tea.Batch(
-				util.CmdHandler(dialogs.CloseDialogMsg{}),
-				util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
-			)
-		case key.Matches(msg, p.keyMap.ToggleDiffMode):
-			if p.supportsDiffView() {
-				if p.diffSplitMode == nil {
-					diffSplitMode := !p.defaultDiffSplitMode
-					p.diffSplitMode = &diffSplitMode
-				} else {
-					*p.diffSplitMode = !*p.diffSplitMode
-				}
-				p.contentDirty = true // Mark content as dirty when diff mode changes
-				return p, nil
-			}
-		case key.Matches(msg, p.keyMap.ScrollDown):
-			if p.supportsDiffView() {
-				p.scrollDown()
-				return p, nil
-			}
-		case key.Matches(msg, p.keyMap.ScrollUp):
-			if p.supportsDiffView() {
-				p.scrollUp()
-				return p, nil
-			}
-		case key.Matches(msg, p.keyMap.ScrollLeft):
-			if p.supportsDiffView() {
-				p.scrollLeft()
-				return p, nil
-			}
-		case key.Matches(msg, p.keyMap.ScrollRight):
-			if p.supportsDiffView() {
-				p.scrollRight()
-				return p, nil
-			}
-		default:
-			// Pass other keys to viewport
-			viewPort, cmd := p.contentViewPort.Update(msg)
-			p.contentViewPort = viewPort
-			cmds = append(cmds, cmd)
-		}
-	case tea.MouseWheelMsg:
-		if p.supportsDiffView() && p.isMouseOverDialog(msg.Mouse().X, msg.Mouse().Y) {
-			switch msg.Button {
-			case tea.MouseWheelDown:
-				p.scrollDown()
-			case tea.MouseWheelUp:
-				p.scrollUp()
-			case tea.MouseWheelLeft:
-				p.scrollLeft()
-			case tea.MouseWheelRight:
-				p.scrollRight()
-			}
-		}
-	}
-
-	return p, tea.Batch(cmds...)
-}
-
-func (p *permissionDialogCmp) scrollDown() {
-	p.diffYOffset += 1
-	p.contentDirty = true
-}
-
-func (p *permissionDialogCmp) scrollUp() {
-	p.diffYOffset = max(0, p.diffYOffset-1)
-	p.contentDirty = true
-}
-
-func (p *permissionDialogCmp) scrollLeft() {
-	p.diffXOffset = max(0, p.diffXOffset-5)
-	p.contentDirty = true
-}
-
-func (p *permissionDialogCmp) scrollRight() {
-	p.diffXOffset += 5
-	p.contentDirty = true
-}
-
-// isMouseOverDialog checks if the given mouse coordinates are within the dialog bounds.
-// Returns true if the mouse is over the dialog area, false otherwise.
-func (p *permissionDialogCmp) isMouseOverDialog(x, y int) bool {
-	if p.permission.ID == "" {
-		return false
-	}
-	var (
-		dialogX      = p.positionCol
-		dialogY      = p.positionRow
-		dialogWidth  = p.width
-		dialogHeight = p.finalDialogHeight
-	)
-	return x >= dialogX && x < dialogX+dialogWidth && y >= dialogY && y < dialogY+dialogHeight
-}
-
-func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
-	var action PermissionAction
-
-	switch p.selectedOption {
-	case 0:
-		action = PermissionAllow
-	case 1:
-		action = PermissionAllowForSession
-	case 2:
-		action = PermissionDeny
-	}
-
-	return tea.Batch(
-		util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
-		util.CmdHandler(dialogs.CloseDialogMsg{}),
-	)
-}
-
-func (p *permissionDialogCmp) renderButtons() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	buttons := []core.ButtonOpts{
-		{
-			Text:           "Allow",
-			UnderlineIndex: 0, // "A"
-			Selected:       p.selectedOption == 0,
-		},
-		{
-			Text:           "Allow for Session",
-			UnderlineIndex: 10, // "S" in "Session"
-			Selected:       p.selectedOption == 1,
-		},
-		{
-			Text:           "Deny",
-			UnderlineIndex: 0, // "D"
-			Selected:       p.selectedOption == 2,
-		},
-	}
-
-	content := core.SelectableButtons(buttons, "  ")
-	if lipgloss.Width(content) > p.width-4 {
-		content = core.SelectableButtonsVertical(buttons, 1)
-		return baseStyle.AlignVertical(lipgloss.Center).
-			AlignHorizontal(lipgloss.Center).
-			Width(p.width - 4).
-			Render(content)
-	}
-
-	return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
-}
-
-func (p *permissionDialogCmp) renderHeader() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	toolKey := t.S().Muted.Render("Tool")
-	toolValue := t.S().Text.
-		Width(p.width - lipgloss.Width(toolKey)).
-		Render(fmt.Sprintf(" %s", p.permission.ToolName))
-
-	pathKey := t.S().Muted.Render("Path")
-	pathValue := t.S().Text.
-		Width(p.width - lipgloss.Width(pathKey)).
-		Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path)))
-
-	headerParts := []string{
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			toolKey,
-			toolValue,
-		),
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			pathKey,
-			pathValue,
-		),
-	}
-
-	// Add tool-specific header information
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		params := p.permission.Params.(tools.BashPermissionsParams)
-		descKey := t.S().Muted.Render("Desc")
-		descValue := t.S().Text.
-			Width(p.width - lipgloss.Width(descKey)).
-			Render(fmt.Sprintf(" %s", params.Description))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				descKey,
-				descValue,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-			t.S().Muted.Width(p.width).Render("Command"),
-		)
-	case tools.DownloadToolName:
-		params := p.permission.Params.(tools.DownloadPermissionsParams)
-		urlKey := t.S().Muted.Render("URL")
-		urlValue := t.S().Text.
-			Width(p.width - lipgloss.Width(urlKey)).
-			Render(fmt.Sprintf(" %s", params.URL))
-		fileKey := t.S().Muted.Render("File")
-		filePath := t.S().Text.
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				urlKey,
-				urlValue,
-			),
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-	case tools.EditToolName:
-		params := p.permission.Params.(tools.EditPermissionsParams)
-		fileKey := t.S().Muted.Render("File")
-		filePath := t.S().Text.
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-
-	case tools.WriteToolName:
-		params := p.permission.Params.(tools.WritePermissionsParams)
-		fileKey := t.S().Muted.Render("File")
-		filePath := t.S().Text.
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-	case tools.MultiEditToolName:
-		params := p.permission.Params.(tools.MultiEditPermissionsParams)
-		fileKey := t.S().Muted.Render("File")
-		filePath := t.S().Text.
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-	case tools.FetchToolName:
-		headerParts = append(headerParts,
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-			t.S().Muted.Width(p.width).Bold(true).Render("URL"),
-		)
-	case tools.AgenticFetchToolName:
-		headerParts = append(headerParts,
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-			t.S().Muted.Width(p.width).Bold(true).Render("Web"),
-		)
-	case tools.ViewToolName:
-		params := p.permission.Params.(tools.ViewPermissionsParams)
-		fileKey := t.S().Muted.Render("File")
-		filePath := t.S().Text.
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-	case tools.LSToolName:
-		params := p.permission.Params.(tools.LSPermissionsParams)
-		pathKey := t.S().Muted.Render("Directory")
-		pathValue := t.S().Text.
-			Width(p.width - lipgloss.Width(pathKey)).
-			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.Path)))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				pathKey,
-				pathValue,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-	}
-
-	return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-}
-
-func (p *permissionDialogCmp) getOrGenerateContent() string {
-	// Return cached content if available and not dirty
-	if !p.contentDirty && p.cachedContent != "" {
-		return p.cachedContent
-	}
-
-	// Generate new content
-	var content string
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		content = p.generateBashContent()
-	case tools.DownloadToolName:
-		content = p.generateDownloadContent()
-	case tools.EditToolName:
-		content = p.generateEditContent()
-	case tools.WriteToolName:
-		content = p.generateWriteContent()
-	case tools.MultiEditToolName:
-		content = p.generateMultiEditContent()
-	case tools.FetchToolName:
-		content = p.generateFetchContent()
-	case tools.AgenticFetchToolName:
-		content = p.generateAgenticFetchContent()
-	case tools.ViewToolName:
-		content = p.generateViewContent()
-	case tools.LSToolName:
-		content = p.generateLSContent()
-	default:
-		content = p.generateDefaultContent()
-	}
-
-	// Cache the result
-	p.cachedContent = content
-	p.contentDirty = false
-
-	return content
-}
-
-func (p *permissionDialogCmp) generateBashContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base.Background(t.BgSubtle)
-	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
-		content := pr.Command
-		t := styles.CurrentTheme()
-		content = strings.TrimSpace(content)
-		lines := strings.Split(content, "\n")
-
-		width := p.width - 4
-		var out []string
-		for _, ln := range lines {
-			out = append(out, t.S().Muted.
-				Width(width).
-				Padding(0, 3).
-				Foreground(t.FgBase).
-				Background(t.BgSubtle).
-				Render(ln))
-		}
-
-		// Ensure minimum of 7 lines for command display
-		minLines := 7
-		for len(out) < minLines {
-			out = append(out, t.S().Muted.
-				Width(width).
-				Padding(0, 3).
-				Foreground(t.FgBase).
-				Background(t.BgSubtle).
-				Render(""))
-		}
-
-		// Use the cache for markdown rendering
-		renderedContent := strings.Join(out, "\n")
-		finalContent := baseStyle.
-			Width(p.contentViewPort.Width()).
-			Padding(1, 0).
-			Render(renderedContent)
-
-		return finalContent
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateEditContent() string {
-	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
-		formatter := core.DiffFormatter().
-			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
-			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
-			Height(p.contentViewPort.Height()).
-			Width(p.contentViewPort.Width()).
-			XOffset(p.diffXOffset).
-			YOffset(p.diffYOffset)
-		if p.useDiffSplitMode() {
-			formatter = formatter.Split()
-		} else {
-			formatter = formatter.Unified()
-		}
-
-		diff := formatter.String()
-		return diff
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateWriteContent() string {
-	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
-		// Use the cache for diff rendering
-		formatter := core.DiffFormatter().
-			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
-			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
-			Height(p.contentViewPort.Height()).
-			Width(p.contentViewPort.Width()).
-			XOffset(p.diffXOffset).
-			YOffset(p.diffYOffset)
-		if p.useDiffSplitMode() {
-			formatter = formatter.Split()
-		} else {
-			formatter = formatter.Unified()
-		}
-
-		diff := formatter.String()
-		return diff
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateDownloadContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base.Background(t.BgSubtle)
-	if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
-		content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath))
-		if pr.Timeout > 0 {
-			content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout)
-		}
-
-		finalContent := baseStyle.
-			Padding(1, 2).
-			Width(p.contentViewPort.Width()).
-			Render(content)
-		return finalContent
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateMultiEditContent() string {
-	if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok {
-		// Use the cache for diff rendering
-		formatter := core.DiffFormatter().
-			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
-			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
-			Height(p.contentViewPort.Height()).
-			Width(p.contentViewPort.Width()).
-			XOffset(p.diffXOffset).
-			YOffset(p.diffYOffset)
-		if p.useDiffSplitMode() {
-			formatter = formatter.Split()
-		} else {
-			formatter = formatter.Unified()
-		}
-
-		diff := formatter.String()
-		return diff
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateFetchContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base.Background(t.BgSubtle)
-	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
-		finalContent := baseStyle.
-			Padding(1, 2).
-			Width(p.contentViewPort.Width()).
-			Render(pr.URL)
-		return finalContent
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateAgenticFetchContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base.Background(t.BgSubtle)
-	if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok {
-		var content string
-		if pr.URL != "" {
-			content = fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
-		} else {
-			content = fmt.Sprintf("Prompt: %s", pr.Prompt)
-		}
-		finalContent := baseStyle.
-			Padding(1, 2).
-			Width(p.contentViewPort.Width()).
-			Render(content)
-		return finalContent
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateViewContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base.Background(t.BgSubtle)
-	if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok {
-		content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath))
-		if pr.Offset > 0 {
-			content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1)
-		}
-		if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit
-			content += fmt.Sprintf("\nLines to read: %d", pr.Limit)
-		}
-
-		finalContent := baseStyle.
-			Padding(1, 2).
-			Width(p.contentViewPort.Width()).
-			Render(content)
-		return finalContent
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateLSContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base.Background(t.BgSubtle)
-	if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
-		content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path))
-		if len(pr.Ignore) > 0 {
-			content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", "))
-		}
-
-		finalContent := baseStyle.
-			Padding(1, 2).
-			Width(p.contentViewPort.Width()).
-			Render(content)
-		return finalContent
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) generateDefaultContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base.Background(t.BgSubtle)
-
-	content := p.permission.Description
-
-	// Add pretty-printed JSON parameters for MCP tools
-	if p.permission.Params != nil {
-		var paramStr string
-
-		// Ensure params is a string
-		if str, ok := p.permission.Params.(string); ok {
-			paramStr = str
-		} else {
-			paramStr = fmt.Sprintf("%v", p.permission.Params)
-		}
-
-		// Try to parse as JSON for pretty printing
-		var parsed any
-		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
-			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
-				if content != "" {
-					content += "\n\n"
-				}
-				content += string(b)
-			}
-		} else {
-			// Not JSON, show as-is
-			if content != "" {
-				content += "\n\n"
-			}
-			content += paramStr
-		}
-	}
-
-	content = strings.TrimSpace(content)
-	content = "\n" + content + "\n"
-	lines := strings.Split(content, "\n")
-
-	width := p.width - 4
-	var out []string
-	for _, ln := range lines {
-		ln = " " + ln // left padding
-		if len(ln) > width {
-			ln = ansi.Truncate(ln, width, "…")
-		}
-		out = append(out, t.S().Muted.
-			Width(width).
-			Foreground(t.FgBase).
-			Background(t.BgSubtle).
-			Render(ln))
-	}
-
-	// Use the cache for markdown rendering
-	renderedContent := strings.Join(out, "\n")
-	finalContent := baseStyle.
-		Width(p.contentViewPort.Width()).
-		Render(renderedContent)
-
-	if renderedContent == "" {
-		return ""
-	}
-
-	return finalContent
-}
-
-func (p *permissionDialogCmp) useDiffSplitMode() bool {
-	if p.diffSplitMode != nil {
-		return *p.diffSplitMode
-	}
-	return p.defaultDiffSplitMode
-}
-
-func (p *permissionDialogCmp) styleViewport() string {
-	t := styles.CurrentTheme()
-	return t.S().Base.Render(p.contentViewPort.View())
-}
-
-func (p *permissionDialogCmp) render() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-	title := core.Title("Permission Required", p.width-4)
-	// Render header
-	headerContent := p.renderHeader()
-	// Render buttons
-	buttons := p.renderButtons()
-
-	p.contentViewPort.SetWidth(p.width - 4)
-
-	// Always set viewport content (the caching is handled in getOrGenerateContent)
-	const minContentHeight = 9
-
-	availableDialogHeight := max(minContentHeight, p.height-minContentHeight)
-	p.contentViewPort.SetHeight(availableDialogHeight)
-	contentFinal := p.getOrGenerateContent()
-	contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal))
-
-	p.contentViewPort.SetHeight(contentHeight)
-	p.contentViewPort.SetContent(contentFinal)
-
-	p.positionRow = p.wHeight / 2
-	p.positionRow -= (contentHeight + 9) / 2
-	p.positionRow -= 3 // Move dialog slightly higher than middle
-
-	var contentHelp string
-	if p.supportsDiffView() {
-		contentHelp = help.New().View(p.keyMap)
-	}
-
-	// Calculate content height dynamically based on window size
-	strs := []string{
-		title,
-		"",
-		headerContent,
-		"",
-		p.styleViewport(),
-		"",
-		buttons,
-		"",
-	}
-	if contentHelp != "" {
-		strs = append(strs, "", contentHelp)
-	}
-	content := lipgloss.JoinVertical(lipgloss.Top, strs...)
-
-	dialog := baseStyle.
-		Padding(0, 1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus).
-		Width(p.width).
-		Render(
-			content,
-		)
-	p.finalDialogHeight = lipgloss.Height(dialog)
-	return dialog
-}
-
-func (p *permissionDialogCmp) View() string {
-	return p.render()
-}
-
-func (p *permissionDialogCmp) SetSize() tea.Cmd {
-	if p.permission.ID == "" {
-		return nil
-	}
-
-	oldWidth, oldHeight := p.width, p.height
-
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.3)
-	case tools.DownloadToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.4)
-	case tools.EditToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.8)
-	case tools.WriteToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.8)
-	case tools.MultiEditToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.8)
-	case tools.FetchToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.3)
-	case tools.AgenticFetchToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.4)
-	case tools.ViewToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.4)
-	case tools.LSToolName:
-		p.width = int(float64(p.wWidth) * 0.8)
-		p.height = int(float64(p.wHeight) * 0.4)
-	default:
-		p.width = int(float64(p.wWidth) * 0.7)
-		p.height = int(float64(p.wHeight) * 0.5)
-	}
-
-	// Default to diff split mode when dialog is wide enough.
-	p.defaultDiffSplitMode = p.width >= 140
-
-	// Set a maximum width for the dialog
-	p.width = min(p.width, 180)
-
-	// Mark content as dirty if size changed
-	if oldWidth != p.width || oldHeight != p.height {
-		p.contentDirty = true
-	}
-	p.positionRow = p.wHeight / 2
-	p.positionRow -= p.height / 2
-	p.positionRow -= 3 // Move dialog slightly higher than middle
-	p.positionCol = p.wWidth / 2
-	p.positionCol -= p.width / 2
-	return nil
-}
-
-func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
-	content, err := generator()
-	if err != nil {
-		return fmt.Sprintf("Error rendering markdown: %v", err)
-	}
-
-	return content
-}
-
-// ID implements PermissionDialogCmp.
-func (p *permissionDialogCmp) ID() dialogs.DialogID {
-	return PermissionsDialogID
-}
-
-// Position implements PermissionDialogCmp.
-func (p *permissionDialogCmp) Position() (int, int) {
-	return p.positionRow, p.positionCol
-}
-
-// Options for create a new permission dialog
-type Options struct {
-	DiffMode string // split or unified, empty means use defaultDiffSplitMode
-}
-
-// isSplitMode returns internal representation of diff mode switch
-func (o Options) isSplitMode() *bool {
-	var split bool
-
-	switch o.DiffMode {
-	case "split":
-		split = true
-	case "unified":
-		split = false
-	default:
-		return nil
-	}
-
-	return &split
-}

internal/tui/components/dialogs/quit/keys.go 🔗

@@ -1,75 +0,0 @@
-package quit
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-// KeyMap defines the keyboard bindings for the quit dialog.
-type KeyMap struct {
-	LeftRight,
-	EnterSpace,
-	Yes,
-	No,
-	Tab,
-	Close key.Binding
-}
-
-func DefaultKeymap() KeyMap {
-	return KeyMap{
-		LeftRight: key.NewBinding(
-			key.WithKeys("left", "right"),
-			key.WithHelp("←/→", "switch options"),
-		),
-		EnterSpace: key.NewBinding(
-			key.WithKeys("enter", " "),
-			key.WithHelp("enter/space", "confirm"),
-		),
-		Yes: key.NewBinding(
-			key.WithKeys("y", "Y", "ctrl+c"),
-			key.WithHelp("y/Y/ctrl+c", "yes"),
-		),
-		No: key.NewBinding(
-			key.WithKeys("n", "N"),
-			key.WithHelp("n/N", "no"),
-		),
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", "switch options"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.LeftRight,
-		k.EnterSpace,
-		k.Yes,
-		k.No,
-		k.Tab,
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.LeftRight,
-		k.EnterSpace,
-	}
-}

internal/tui/components/dialogs/quit/quit.go 🔗

@@ -1,120 +0,0 @@
-package quit
-
-import (
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
-	question                      = "Are you sure you want to quit?"
-	QuitDialogID dialogs.DialogID = "quit"
-)
-
-// QuitDialog represents a confirmation dialog for quitting the application.
-type QuitDialog interface {
-	dialogs.DialogModel
-}
-
-type quitDialogCmp struct {
-	wWidth  int
-	wHeight int
-
-	selectedNo bool // true if "No" button is selected
-	keymap     KeyMap
-}
-
-// NewQuitDialog creates a new quit confirmation dialog.
-func NewQuitDialog() QuitDialog {
-	return &quitDialogCmp{
-		selectedNo: true, // Default to "No" for safety
-		keymap:     DefaultKeymap(),
-	}
-}
-
-func (q *quitDialogCmp) Init() tea.Cmd {
-	return nil
-}
-
-// Update handles keyboard input for the quit dialog.
-func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		q.wWidth = msg.Width
-		q.wHeight = msg.Height
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab):
-			q.selectedNo = !q.selectedNo
-			return q, nil
-		case key.Matches(msg, q.keymap.EnterSpace):
-			if !q.selectedNo {
-				return q, tea.Quit
-			}
-			return q, util.CmdHandler(dialogs.CloseDialogMsg{})
-		case key.Matches(msg, q.keymap.Yes):
-			return q, tea.Quit
-		case key.Matches(msg, q.keymap.No, q.keymap.Close):
-			return q, util.CmdHandler(dialogs.CloseDialogMsg{})
-		}
-	}
-	return q, nil
-}
-
-// View renders the quit dialog with Yes/No buttons.
-func (q *quitDialogCmp) View() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-	yesStyle := t.S().Text
-	noStyle := yesStyle
-
-	if q.selectedNo {
-		noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
-		yesStyle = yesStyle.Background(t.BgSubtle)
-	} else {
-		yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
-		noStyle = noStyle.Background(t.BgSubtle)
-	}
-
-	const horizontalPadding = 3
-	yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") +
-		yesStyle.PaddingRight(horizontalPadding).Render("ep!")
-	noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") +
-		noStyle.PaddingRight(horizontalPadding).Render("ope")
-
-	buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
-		lipgloss.JoinHorizontal(lipgloss.Center, yesButton, "  ", noButton),
-	)
-
-	content := baseStyle.Render(
-		lipgloss.JoinVertical(
-			lipgloss.Center,
-			question,
-			"",
-			buttons,
-		),
-	)
-
-	quitDialogStyle := baseStyle.
-		Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus)
-
-	return quitDialogStyle.Render(content)
-}
-
-func (q *quitDialogCmp) Position() (int, int) {
-	row := q.wHeight / 2
-	row -= 7 / 2
-	col := q.wWidth / 2
-	col -= (lipgloss.Width(question) + 4) / 2
-
-	return row, col
-}
-
-func (q *quitDialogCmp) ID() dialogs.DialogID {
-	return QuitDialogID
-}

internal/tui/components/dialogs/reasoning/reasoning.go 🔗

@@ -1,264 +0,0 @@
-package reasoning
-
-import (
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
-	ReasoningDialogID dialogs.DialogID = "reasoning"
-
-	defaultWidth int = 50
-)
-
-type listModel = list.FilterableList[list.CompletionItem[EffortOption]]
-
-type EffortOption struct {
-	Title  string
-	Effort string
-}
-
-type ReasoningDialog interface {
-	dialogs.DialogModel
-}
-
-type reasoningDialogCmp struct {
-	width   int
-	wWidth  int // Width of the terminal window
-	wHeight int // Height of the terminal window
-
-	effortList listModel
-	keyMap     ReasoningDialogKeyMap
-	help       help.Model
-}
-
-type ReasoningEffortSelectedMsg struct {
-	Effort string
-}
-
-type ReasoningDialogKeyMap struct {
-	Next     key.Binding
-	Previous key.Binding
-	Select   key.Binding
-	Close    key.Binding
-}
-
-func DefaultReasoningDialogKeyMap() ReasoningDialogKeyMap {
-	return ReasoningDialogKeyMap{
-		Next: key.NewBinding(
-			key.WithKeys("down", "j", "ctrl+n"),
-			key.WithHelp("↓/j/ctrl+n", "next"),
-		),
-		Previous: key.NewBinding(
-			key.WithKeys("up", "k", "ctrl+p"),
-			key.WithHelp("↑/k/ctrl+p", "previous"),
-		),
-		Select: key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "select"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "ctrl+c"),
-			key.WithHelp("esc/ctrl+c", "close"),
-		),
-	}
-}
-
-func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{k.Select, k.Close}
-}
-
-func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
-	return [][]key.Binding{
-		{k.Next, k.Previous},
-		{k.Select, k.Close},
-	}
-}
-
-func NewReasoningDialog() ReasoningDialog {
-	keyMap := DefaultReasoningDialogKeyMap()
-	listKeyMap := list.DefaultKeyMap()
-	listKeyMap.Down.SetEnabled(false)
-	listKeyMap.Up.SetEnabled(false)
-	listKeyMap.DownOneItem = keyMap.Next
-	listKeyMap.UpOneItem = keyMap.Previous
-
-	t := styles.CurrentTheme()
-	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
-	effortList := list.NewFilterableList(
-		[]list.CompletionItem[EffortOption]{},
-		list.WithFilterInputStyle(inputStyle),
-		list.WithFilterListOptions(
-			list.WithKeyMap(listKeyMap),
-			list.WithWrapNavigation(),
-			list.WithResizeByList(),
-		),
-	)
-	help := help.New()
-	help.Styles = t.S().Help
-
-	return &reasoningDialogCmp{
-		effortList: effortList,
-		width:      defaultWidth,
-		keyMap:     keyMap,
-		help:       help,
-	}
-}
-
-func (r *reasoningDialogCmp) Init() tea.Cmd {
-	return r.populateEffortOptions()
-}
-
-func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
-	cfg := config.Get()
-	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
-		selectedModel := cfg.Models[agentCfg.Model]
-		model := cfg.GetModelByType(agentCfg.Model)
-
-		// Get current reasoning effort
-		currentEffort := selectedModel.ReasoningEffort
-		if currentEffort == "" && model != nil {
-			currentEffort = model.DefaultReasoningEffort
-		}
-
-		efforts := []EffortOption{}
-		caser := cases.Title(language.Und)
-		for _, level := range model.ReasoningLevels {
-			efforts = append(efforts, EffortOption{
-				Title:  caser.String(level),
-				Effort: level,
-			})
-		}
-
-		effortItems := []list.CompletionItem[EffortOption]{}
-		selectedID := ""
-		for _, effort := range efforts {
-			opts := []list.CompletionItemOption{
-				list.WithCompletionID(effort.Effort),
-			}
-			if effort.Effort == currentEffort {
-				opts = append(opts, list.WithCompletionShortcut("current"))
-				selectedID = effort.Effort
-			}
-			effortItems = append(effortItems, list.NewCompletionItem(
-				effort.Title,
-				effort,
-				opts...,
-			))
-		}
-
-		cmd := r.effortList.SetItems(effortItems)
-		// Set the current effort as the selected item
-		if currentEffort != "" && selectedID != "" {
-			return tea.Sequence(cmd, r.effortList.SetSelected(selectedID))
-		}
-		return cmd
-	}
-	return nil
-}
-
-func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		r.wWidth = msg.Width
-		r.wHeight = msg.Height
-		return r, r.effortList.SetSize(r.listWidth(), r.listHeight())
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, r.keyMap.Select):
-			selectedItem := r.effortList.SelectedItem()
-			if selectedItem == nil {
-				return r, nil // No item selected, do nothing
-			}
-			effort := (*selectedItem).Value()
-			return r, tea.Sequence(
-				util.CmdHandler(dialogs.CloseDialogMsg{}),
-				func() tea.Msg {
-					return ReasoningEffortSelectedMsg{
-						Effort: effort.Effort,
-					}
-				},
-			)
-		case key.Matches(msg, r.keyMap.Close):
-			return r, util.CmdHandler(dialogs.CloseDialogMsg{})
-		default:
-			u, cmd := r.effortList.Update(msg)
-			r.effortList = u.(listModel)
-			return r, cmd
-		}
-	}
-	return r, nil
-}
-
-func (r *reasoningDialogCmp) View() string {
-	t := styles.CurrentTheme()
-	listView := r.effortList
-
-	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4))
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		header,
-		listView.View(),
-		"",
-		t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)),
-	)
-	return r.style().Render(content)
-}
-
-func (r *reasoningDialogCmp) Cursor() *tea.Cursor {
-	if cursor, ok := r.effortList.(util.Cursor); ok {
-		cursor := cursor.Cursor()
-		if cursor != nil {
-			cursor = r.moveCursor(cursor)
-		}
-		return cursor
-	}
-	return nil
-}
-
-func (r *reasoningDialogCmp) listWidth() int {
-	return r.width - 2 // 4 for padding
-}
-
-func (r *reasoningDialogCmp) listHeight() int {
-	listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
-	return min(listHeight, r.wHeight/2)
-}
-
-func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
-	row, col := r.Position()
-	offset := row + 3
-	cursor.Y += offset
-	cursor.X = cursor.X + col + 2
-	return cursor
-}
-
-func (r *reasoningDialogCmp) style() lipgloss.Style {
-	t := styles.CurrentTheme()
-	return t.S().Base.
-		Width(r.width).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus)
-}
-
-func (r *reasoningDialogCmp) Position() (int, int) {
-	row := r.wHeight/4 - 2 // just a bit above the center
-	col := r.wWidth / 2
-	col -= r.width / 2
-	return row, col
-}
-
-func (r *reasoningDialogCmp) ID() dialogs.DialogID {
-	return ReasoningDialogID
-}

internal/tui/components/dialogs/sessions/keys.go 🔗

@@ -1,67 +0,0 @@
-package sessions
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	Select,
-	Next,
-	Previous,
-	Close key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Select: key.NewBinding(
-			key.WithKeys("enter", "tab", "ctrl+y"),
-			key.WithHelp("enter", "choose"),
-		),
-		Next: key.NewBinding(
-			key.WithKeys("down", "ctrl+n"),
-			key.WithHelp("↓", "next item"),
-		),
-		Previous: key.NewBinding(
-			key.WithKeys("up", "ctrl+p"),
-			key.WithHelp("↑", "previous item"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "exit"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Select,
-		k.Next,
-		k.Previous,
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		key.NewBinding(
-
-			key.WithKeys("down", "up"),
-			key.WithHelp("↑↓", "choose"),
-		),
-		k.Select,
-		k.Close,
-	}
-}

internal/tui/components/dialogs/sessions/sessions.go 🔗

@@ -1,181 +0,0 @@
-package sessions
-
-import (
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/exp/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const SessionsDialogID dialogs.DialogID = "sessions"
-
-// SessionDialog interface for the session switching dialog
-type SessionDialog interface {
-	dialogs.DialogModel
-}
-
-type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
-
-type sessionDialogCmp struct {
-	selectedInx       int
-	wWidth            int
-	wHeight           int
-	width             int
-	selectedSessionID string
-	keyMap            KeyMap
-	sessionsList      SessionsList
-	help              help.Model
-}
-
-// NewSessionDialogCmp creates a new session switching dialog
-func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog {
-	t := styles.CurrentTheme()
-	listKeyMap := list.DefaultKeyMap()
-	keyMap := DefaultKeyMap()
-	listKeyMap.Down.SetEnabled(false)
-	listKeyMap.Up.SetEnabled(false)
-	listKeyMap.DownOneItem = keyMap.Next
-	listKeyMap.UpOneItem = keyMap.Previous
-
-	items := make([]list.CompletionItem[session.Session], len(sessions))
-	if len(sessions) > 0 {
-		for i, session := range sessions {
-			items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
-		}
-	}
-
-	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
-	sessionsList := list.NewFilterableList(
-		items,
-		list.WithFilterPlaceholder("Enter a session name"),
-		list.WithFilterInputStyle(inputStyle),
-		list.WithFilterListOptions(
-			list.WithKeyMap(listKeyMap),
-			list.WithWrapNavigation(),
-		),
-	)
-	help := help.New()
-	help.Styles = t.S().Help
-	s := &sessionDialogCmp{
-		selectedSessionID: selectedID,
-		keyMap:            DefaultKeyMap(),
-		sessionsList:      sessionsList,
-		help:              help,
-	}
-
-	return s
-}
-
-func (s *sessionDialogCmp) Init() tea.Cmd {
-	var cmds []tea.Cmd
-	cmds = append(cmds, s.sessionsList.Init())
-	cmds = append(cmds, s.sessionsList.Focus())
-	return tea.Sequence(cmds...)
-}
-
-func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		var cmds []tea.Cmd
-		s.wWidth = msg.Width
-		s.wHeight = msg.Height
-		s.width = min(120, s.wWidth-8)
-		s.sessionsList.SetInputWidth(s.listWidth() - 2)
-		cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
-		if s.selectedSessionID != "" {
-			cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
-		}
-		return s, tea.Batch(cmds...)
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, s.keyMap.Select):
-			selectedItem := s.sessionsList.SelectedItem()
-			if selectedItem != nil {
-				selected := *selectedItem
-				event.SessionSwitched()
-				return s, tea.Sequence(
-					util.CmdHandler(dialogs.CloseDialogMsg{}),
-					util.CmdHandler(
-						chat.SessionSelectedMsg(selected.Value()),
-					),
-				)
-			}
-		case key.Matches(msg, s.keyMap.Close):
-			return s, util.CmdHandler(dialogs.CloseDialogMsg{})
-		default:
-			u, cmd := s.sessionsList.Update(msg)
-			s.sessionsList = u.(SessionsList)
-			return s, cmd
-		}
-	}
-	return s, nil
-}
-
-func (s *sessionDialogCmp) View() string {
-	t := styles.CurrentTheme()
-	listView := s.sessionsList.View()
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)),
-		listView,
-		"",
-		t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)),
-	)
-
-	return s.style().Render(content)
-}
-
-func (s *sessionDialogCmp) Cursor() *tea.Cursor {
-	if cursor, ok := s.sessionsList.(util.Cursor); ok {
-		cursor := cursor.Cursor()
-		if cursor != nil {
-			cursor = s.moveCursor(cursor)
-		}
-		return cursor
-	}
-	return nil
-}
-
-func (s *sessionDialogCmp) style() lipgloss.Style {
-	t := styles.CurrentTheme()
-	return t.S().Base.
-		Width(s.width).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus)
-}
-
-func (s *sessionDialogCmp) listHeight() int {
-	return s.wHeight/2 - 6 // 5 for the border, title and help
-}
-
-func (s *sessionDialogCmp) listWidth() int {
-	return s.width - 2 // 2 for the border
-}
-
-func (s *sessionDialogCmp) Position() (int, int) {
-	row := s.wHeight/4 - 2 // just a bit above the center
-	col := s.wWidth / 2
-	col -= s.width / 2
-	return row, col
-}
-
-func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
-	row, col := s.Position()
-	offset := row + 3 // Border + title
-	cursor.Y += offset
-	cursor.X = cursor.X + col + 2
-	return cursor
-}
-
-// ID implements SessionDialog.
-func (s *sessionDialogCmp) ID() dialogs.DialogID {
-	return SessionsDialogID
-}

internal/tui/components/files/files.go 🔗

@@ -1,146 +0,0 @@
-package files
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/x/ansi"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-// FileHistory represents a file history with initial and latest versions.
-type FileHistory struct {
-	InitialVersion history.File
-	LatestVersion  history.File
-}
-
-// SessionFile represents a file with its history information.
-type SessionFile struct {
-	History   FileHistory
-	FilePath  string
-	Additions int
-	Deletions int
-}
-
-// RenderOptions contains options for rendering file lists.
-type RenderOptions struct {
-	MaxWidth    int
-	MaxItems    int
-	ShowSection bool
-	SectionName string
-}
-
-// RenderFileList renders a list of file status items with the given options.
-func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
-	t := styles.CurrentTheme()
-	fileList := []string{}
-
-	if opts.ShowSection {
-		sectionName := opts.SectionName
-		if sectionName == "" {
-			sectionName = "Modified Files"
-		}
-		section := t.S().Subtle.Render(sectionName)
-		fileList = append(fileList, section, "")
-	}
-
-	if len(fileSlice) == 0 {
-		fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None"))
-		return fileList
-	}
-
-	// Sort files by the latest version's created time
-	sort.Slice(fileSlice, func(i, j int) bool {
-		if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt {
-			return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0
-		}
-		return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
-	})
-
-	// Determine how many items to show
-	maxItems := len(fileSlice)
-	if opts.MaxItems > 0 {
-		maxItems = min(opts.MaxItems, len(fileSlice))
-	}
-
-	filesShown := 0
-	for _, file := range fileSlice {
-		if file.Additions == 0 && file.Deletions == 0 {
-			continue // skip files with no changes
-		}
-		if filesShown >= maxItems {
-			break
-		}
-
-		var statusParts []string
-		if file.Additions > 0 {
-			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
-		}
-		if file.Deletions > 0 {
-			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
-		}
-
-		extraContent := strings.Join(statusParts, " ")
-		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
-		filePath := file.FilePath
-		if rel, err := filepath.Rel(cwd, filePath); err == nil {
-			filePath = rel
-		}
-		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
-		filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…")
-
-		fileList = append(fileList,
-			core.Status(
-				core.StatusOpts{
-					Title:        filePath,
-					ExtraContent: extraContent,
-				},
-				opts.MaxWidth,
-			),
-		)
-		filesShown++
-	}
-
-	return fileList
-}
-
-// RenderFileBlock renders a complete file block with optional truncation indicator.
-func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
-	t := styles.CurrentTheme()
-	fileList := RenderFileList(fileSlice, opts)
-
-	// Add truncation indicator if needed
-	if showTruncationIndicator && opts.MaxItems > 0 {
-		totalFilesWithChanges := 0
-		for _, file := range fileSlice {
-			if file.Additions > 0 || file.Deletions > 0 {
-				totalFilesWithChanges++
-			}
-		}
-		if totalFilesWithChanges > opts.MaxItems {
-			remaining := totalFilesWithChanges - opts.MaxItems
-			if remaining == 1 {
-				fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
-			} else {
-				fileList = append(fileList,
-					t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
-				)
-			}
-		}
-	}
-
-	content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
-	if opts.MaxWidth > 0 {
-		return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
-	}
-	return content
-}

internal/tui/components/image/image.go 🔗

@@ -1,86 +0,0 @@
-// Based on the implementation by @trashhalo at:
-// https://github.com/trashhalo/imgcat
-package image
-
-import (
-	"fmt"
-	_ "image/jpeg"
-	_ "image/png"
-
-	tea "charm.land/bubbletea/v2"
-)
-
-type Model struct {
-	url    string
-	image  string
-	width  uint
-	height uint
-	err    error
-}
-
-func New(width, height uint, url string) Model {
-	return Model{
-		width:  width,
-		height: height,
-		url:    url,
-	}
-}
-
-func (m Model) Init() tea.Cmd {
-	return nil
-}
-
-func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case errMsg:
-		m.err = msg
-		return m, nil
-	case redrawMsg:
-		m.width = msg.width
-		m.height = msg.height
-		m.url = msg.url
-		return m, loadURL(m.url)
-	case loadMsg:
-		return handleLoadMsg(m, msg)
-	}
-	return m, nil
-}
-
-func (m Model) View() string {
-	if m.err != nil {
-		return fmt.Sprintf("couldn't load image(s): %v", m.err)
-	}
-	return m.image
-}
-
-type errMsg struct{ error }
-
-func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
-	return func() tea.Msg {
-		return redrawMsg{
-			width:  width,
-			height: height,
-			url:    url,
-		}
-	}
-}
-
-func (m Model) UpdateURL(url string) tea.Cmd {
-	return func() tea.Msg {
-		return redrawMsg{
-			width:  m.width,
-			height: m.height,
-			url:    url,
-		}
-	}
-}
-
-type redrawMsg struct {
-	width  uint
-	height uint
-	url    string
-}
-
-func (m Model) IsLoading() bool {
-	return m.image == ""
-}

internal/tui/components/image/load.go 🔗

@@ -1,169 +0,0 @@
-// Based on the implementation by @trashhalo at:
-// https://github.com/trashhalo/imgcat
-package image
-
-import (
-	"bytes"
-	"context"
-	"encoding/base64"
-	"image"
-	"image/png"
-	"io"
-	"net/http"
-	"os"
-	"strings"
-
-	tea "charm.land/bubbletea/v2"
-	"github.com/disintegration/imageorient"
-	"github.com/lucasb-eyer/go-colorful"
-	"github.com/muesli/termenv"
-	"github.com/nfnt/resize"
-	"github.com/srwiley/oksvg"
-	"github.com/srwiley/rasterx"
-)
-
-type loadMsg struct {
-	io.ReadCloser
-}
-
-func loadURL(url string) tea.Cmd {
-	var r io.ReadCloser
-	var err error
-
-	if strings.HasPrefix(url, "http") {
-		var resp *http.Request
-		resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
-		r = resp.Body
-	} else {
-		r, err = os.Open(url)
-	}
-
-	if err != nil {
-		return func() tea.Msg {
-			return errMsg{err}
-		}
-	}
-
-	return load(r)
-}
-
-func load(r io.ReadCloser) tea.Cmd {
-	return func() tea.Msg {
-		return loadMsg{r}
-	}
-}
-
-func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
-	defer msg.Close()
-
-	img, err := readerToImage(m.width, m.height, m.url, msg)
-	if err != nil {
-		return m, func() tea.Msg { return errMsg{err} }
-	}
-	m.image = img
-	return m, nil
-}
-
-func imageToString(width, height uint, img image.Image) (string, error) {
-	img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
-	b := img.Bounds()
-	w := b.Max.X
-	h := b.Max.Y
-	p := termenv.ColorProfile()
-	str := strings.Builder{}
-	for y := 0; y < h; y += 2 {
-		for x := w; x < int(width); x = x + 2 {
-			str.WriteString(" ")
-		}
-		for x := range w {
-			c1, _ := colorful.MakeColor(img.At(x, y))
-			color1 := p.Color(c1.Hex())
-			c2, _ := colorful.MakeColor(img.At(x, y+1))
-			color2 := p.Color(c2.Hex())
-			str.WriteString(termenv.String("▀").
-				Foreground(color1).
-				Background(color2).
-				String())
-		}
-		str.WriteString("\n")
-	}
-	return str.String(), nil
-}
-
-func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) {
-	if strings.HasSuffix(strings.ToLower(url), ".svg") {
-		return svgToImage(width, height, r)
-	}
-
-	img, _, err := imageorient.Decode(r)
-	if err != nil {
-		return "", err
-	}
-
-	return imageToString(width, height, img)
-}
-
-func svgToImage(width uint, height uint, r io.Reader) (string, error) {
-	// Original author: https://stackoverflow.com/users/10826783/usual-human
-	// https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
-	// Adapted to use size from SVG, and to use temp file.
-
-	tmpPngFile, err := os.CreateTemp("", "img.*.png")
-	if err != nil {
-		return "", err
-	}
-	tmpPngPath := tmpPngFile.Name()
-	defer os.Remove(tmpPngPath)
-	defer tmpPngFile.Close()
-
-	// Rasterize the SVG:
-	icon, err := oksvg.ReadIconStream(r)
-	if err != nil {
-		return "", err
-	}
-	w := int(icon.ViewBox.W)
-	h := int(icon.ViewBox.H)
-	icon.SetTarget(0, 0, float64(w), float64(h))
-	rgba := image.NewRGBA(image.Rect(0, 0, w, h))
-	icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1)
-	// Write rasterized image as PNG:
-	err = png.Encode(tmpPngFile, rgba)
-	if err != nil {
-		tmpPngFile.Close()
-		return "", err
-	}
-	tmpPngFile.Close()
-
-	rPng, err := os.Open(tmpPngPath)
-	if err != nil {
-		return "", err
-	}
-	defer rPng.Close()
-
-	img, _, err := imageorient.Decode(rPng)
-	if err != nil {
-		return "", err
-	}
-	return imageToString(width, height, img)
-}
-
-// ImageFromBase64 renders an image from base64-encoded data.
-func ImageFromBase64(width, height uint, data, mediaType string) (string, error) {
-	decoded, err := base64.StdEncoding.DecodeString(data)
-	if err != nil {
-		return "", err
-	}
-
-	r := bytes.NewReader(decoded)
-
-	if strings.Contains(mediaType, "svg") {
-		return svgToImage(width, height, r)
-	}
-
-	img, _, err := imageorient.Decode(r)
-	if err != nil {
-		return "", err
-	}
-
-	return imageToString(width, height, img)
-}

internal/tui/components/logo/logo.go 🔗

@@ -1,346 +0,0 @@
-// Package logo renders a Crush wordmark in a stylized way.
-package logo
-
-import (
-	"fmt"
-	"image/color"
-	"strings"
-
-	"charm.land/lipgloss/v2"
-	"github.com/MakeNowJust/heredoc"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/charmbracelet/x/exp/slice"
-)
-
-// letterform represents a letterform. It can be stretched horizontally by
-// a given amount via the boolean argument.
-type letterform func(bool) string
-
-const diag = `╱`
-
-// Opts are the options for rendering the Crush title art.
-type Opts struct {
-	FieldColor   color.Color // diagonal lines
-	TitleColorA  color.Color // left gradient ramp point
-	TitleColorB  color.Color // right gradient ramp point
-	CharmColor   color.Color // Charm™ text color
-	VersionColor color.Color // Version text color
-	Width        int         // width of the rendered logo, used for truncation
-}
-
-// Render renders the Crush logo. Set the argument to true to render the narrow
-// version, intended for use in a sidebar.
-//
-// The compact argument determines whether it renders compact for the sidebar
-// or wider for the main pane.
-func Render(version string, compact bool, o Opts) string {
-	const charm = " Charm™"
-
-	fg := func(c color.Color, s string) string {
-		return lipgloss.NewStyle().Foreground(c).Render(s)
-	}
-
-	// Title.
-	const spacing = 1
-	letterforms := []letterform{
-		letterC,
-		letterR,
-		letterU,
-		letterSStylized,
-		letterH,
-	}
-	stretchIndex := -1 // -1 means no stretching.
-	if !compact {
-		stretchIndex = cachedRandN(len(letterforms))
-	}
-
-	crush := renderWord(spacing, stretchIndex, letterforms...)
-	crushWidth := lipgloss.Width(crush)
-	b := new(strings.Builder)
-	for r := range strings.SplitSeq(crush, "\n") {
-		fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
-	}
-	crush = b.String()
-
-	// Charm and version.
-	metaRowGap := 1
-	maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
-	version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
-	gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
-	metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
-
-	// Join the meta row and big Crush title.
-	crush = strings.TrimSpace(metaRow + "\n" + crush)
-
-	// Narrow version.
-	if compact {
-		field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
-		return strings.Join([]string{field, field, crush, field, ""}, "\n")
-	}
-
-	fieldHeight := lipgloss.Height(crush)
-
-	// Left field.
-	const leftWidth = 6
-	leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
-	leftField := new(strings.Builder)
-	for range fieldHeight {
-		fmt.Fprintln(leftField, leftFieldRow)
-	}
-
-	// Right field.
-	rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
-	const stepDownAt = 0
-	rightField := new(strings.Builder)
-	for i := range fieldHeight {
-		width := rightWidth
-		if i >= stepDownAt {
-			width = rightWidth - (i - stepDownAt)
-		}
-		fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
-	}
-
-	// Return the wide version.
-	const hGap = " "
-	logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
-	if o.Width > 0 {
-		// Truncate the logo to the specified width.
-		lines := strings.Split(logo, "\n")
-		for i, line := range lines {
-			lines[i] = ansi.Truncate(line, o.Width, "")
-		}
-		logo = strings.Join(lines, "\n")
-	}
-	return logo
-}
-
-// SmallRender renders a smaller version of the Crush logo, suitable for
-// smaller windows or sidebar usage.
-func SmallRender(width int) string {
-	t := styles.CurrentTheme()
-	title := t.S().Base.Foreground(t.Secondary).Render("Charm™")
-	title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
-	remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
-	if remainingWidth > 0 {
-		lines := strings.Repeat("╱", remainingWidth)
-		title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
-	}
-	return title
-}
-
-// renderWord renders letterforms to fork a word. stretchIndex is the index of
-// the letter to stretch, or -1 if no letter should be stretched.
-func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
-	if spacing < 0 {
-		spacing = 0
-	}
-
-	renderedLetterforms := make([]string, len(letterforms))
-
-	// pick one letter randomly to stretch
-	for i, letter := range letterforms {
-		renderedLetterforms[i] = letter(i == stretchIndex)
-	}
-
-	if spacing > 0 {
-		// Add spaces between the letters and render.
-		renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
-	}
-	return strings.TrimSpace(
-		lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
-	)
-}
-
-// letterC renders the letter C in a stylized way. It takes an integer that
-// determines how many cells to stretch the letter. If the stretch is less than
-// 1, it defaults to no stretching.
-func letterC(stretch bool) string {
-	// Here's what we're making:
-	//
-	// ▄▀▀▀▀
-	// █
-	//	▀▀▀▀
-
-	left := heredoc.Doc(`
-		▄
-		█
-	`)
-	right := heredoc.Doc(`
-		▀
-
-		▀
-	`)
-	return joinLetterform(
-		left,
-		stretchLetterformPart(right, letterformProps{
-			stretch:    stretch,
-			width:      4,
-			minStretch: 7,
-			maxStretch: 12,
-		}),
-	)
-}
-
-// letterH renders the letter H in a stylized way. It takes an integer that
-// determines how many cells to stretch the letter. If the stretch is less than
-// 1, it defaults to no stretching.
-func letterH(stretch bool) string {
-	// Here's what we're making:
-	//
-	// █   █
-	// █▀▀▀█
-	// ▀   ▀
-
-	side := heredoc.Doc(`
-		█
-		█
-		▀`)
-	middle := heredoc.Doc(`
-
-		▀
-	`)
-	return joinLetterform(
-		side,
-		stretchLetterformPart(middle, letterformProps{
-			stretch:    stretch,
-			width:      3,
-			minStretch: 8,
-			maxStretch: 12,
-		}),
-		side,
-	)
-}
-
-// letterR renders the letter R in a stylized way. It takes an integer that
-// determines how many cells to stretch the letter. If the stretch is less than
-// 1, it defaults to no stretching.
-func letterR(stretch bool) string {
-	// Here's what we're making:
-	//
-	// █▀▀▀▄
-	// █▀▀▀▄
-	// ▀   ▀
-
-	left := heredoc.Doc(`
-		█
-		█
-		▀
-	`)
-	center := heredoc.Doc(`
-		▀
-		▀
-	`)
-	right := heredoc.Doc(`
-		▄
-		▄
-		▀
-	`)
-	return joinLetterform(
-		left,
-		stretchLetterformPart(center, letterformProps{
-			stretch:    stretch,
-			width:      3,
-			minStretch: 7,
-			maxStretch: 12,
-		}),
-		right,
-	)
-}
-
-// letterSStylized renders the letter S in a stylized way, more so than
-// [letterS]. It takes an integer that determines how many cells to stretch the
-// letter. If the stretch is less than 1, it defaults to no stretching.
-func letterSStylized(stretch bool) string {
-	// Here's what we're making:
-	//
-	// ▄▀▀▀▀▀
-	// ▀▀▀▀▀█
-	// ▀▀▀▀▀
-
-	left := heredoc.Doc(`
-		▄
-		▀
-		▀
-	`)
-	center := heredoc.Doc(`
-		▀
-		▀
-		▀
-	`)
-	right := heredoc.Doc(`
-		▀
-		█
-	`)
-	return joinLetterform(
-		left,
-		stretchLetterformPart(center, letterformProps{
-			stretch:    stretch,
-			width:      3,
-			minStretch: 7,
-			maxStretch: 12,
-		}),
-		right,
-	)
-}
-
-// letterU renders the letter U in a stylized way. It takes an integer that
-// determines how many cells to stretch the letter. If the stretch is less than
-// 1, it defaults to no stretching.
-func letterU(stretch bool) string {
-	// Here's what we're making:
-	//
-	// █   █
-	// █   █
-	//	▀▀▀
-
-	side := heredoc.Doc(`
-		█
-		█
-	`)
-	middle := heredoc.Doc(`
-
-
-		▀
-	`)
-	return joinLetterform(
-		side,
-		stretchLetterformPart(middle, letterformProps{
-			stretch:    stretch,
-			width:      3,
-			minStretch: 7,
-			maxStretch: 12,
-		}),
-		side,
-	)
-}
-
-func joinLetterform(letters ...string) string {
-	return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
-}
-
-// letterformProps defines letterform stretching properties.
-// for readability.
-type letterformProps struct {
-	width      int
-	minStretch int
-	maxStretch int
-	stretch    bool
-}
-
-// stretchLetterformPart is a helper function for letter stretching. If randomize
-// is false the minimum number will be used.
-func stretchLetterformPart(s string, p letterformProps) string {
-	if p.maxStretch < p.minStretch {
-		p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
-	}
-	n := p.width
-	if p.stretch {
-		n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
-	}
-	parts := make([]string, n)
-	for i := range parts {
-		parts[i] = s
-	}
-	return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
-}

internal/tui/components/logo/rand.go 🔗

@@ -1,24 +0,0 @@
-package logo
-
-import (
-	"math/rand/v2"
-	"sync"
-)
-
-var (
-	randCaches   = make(map[int]int)
-	randCachesMu sync.Mutex
-)
-
-func cachedRandN(n int) int {
-	randCachesMu.Lock()
-	defer randCachesMu.Unlock()
-
-	if n, ok := randCaches[n]; ok {
-		return n
-	}
-
-	r := rand.IntN(n)
-	randCaches[n] = r
-	return r
-}

internal/tui/components/lsp/lsp.go 🔗

@@ -1,144 +0,0 @@
-package lsp
-
-import (
-	"fmt"
-	"maps"
-	"slices"
-	"strings"
-
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-// RenderOptions contains options for rendering LSP lists.
-type RenderOptions struct {
-	MaxWidth    int
-	MaxItems    int
-	ShowSection bool
-	SectionName string
-}
-
-// RenderLSPList renders a list of LSP status items with the given options.
-func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions) []string {
-	t := styles.CurrentTheme()
-	lspList := []string{}
-
-	if opts.ShowSection {
-		sectionName := opts.SectionName
-		if sectionName == "" {
-			sectionName = "LSPs"
-		}
-		section := t.S().Subtle.Render(sectionName)
-		lspList = append(lspList, section, "")
-	}
-
-	// Get LSP states
-	lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
-		return strings.Compare(a.Name, b.Name)
-	})
-	if len(lsps) == 0 {
-		lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
-		return lspList
-	}
-
-	// Determine how many items to show
-	maxItems := len(lsps)
-	if opts.MaxItems > 0 {
-		maxItems = min(opts.MaxItems, len(lsps))
-	}
-
-	for i, info := range lsps {
-		if i >= maxItems {
-			break
-		}
-
-		icon, description := iconAndDescription(t, info)
-
-		// Calculate diagnostic counts if we have LSP clients
-		var extraContent string
-		if lspClients != nil {
-			if client, ok := lspClients.Get(info.Name); ok {
-				counts := client.GetDiagnosticCounts()
-				errs := []string{}
-				if counts.Error > 0 {
-					errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, counts.Error)))
-				}
-				if counts.Warning > 0 {
-					errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, counts.Warning)))
-				}
-				if counts.Hint > 0 {
-					errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, counts.Hint)))
-				}
-				if counts.Information > 0 {
-					errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, counts.Information)))
-				}
-				extraContent = strings.Join(errs, " ")
-			}
-		}
-
-		lspList = append(lspList,
-			core.Status(
-				core.StatusOpts{
-					Icon:         icon.String(),
-					Title:        info.Name,
-					Description:  description,
-					ExtraContent: extraContent,
-				},
-				opts.MaxWidth,
-			),
-		)
-	}
-
-	return lspList
-}
-
-func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
-	switch info.State {
-	case lsp.StateStarting:
-		return t.ItemBusyIcon, t.S().Subtle.Render("starting...")
-	case lsp.StateReady:
-		return t.ItemOnlineIcon, ""
-	case lsp.StateError:
-		description := t.S().Subtle.Render("error")
-		if info.Error != nil {
-			description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error()))
-		}
-		return t.ItemErrorIcon, description
-	case lsp.StateDisabled:
-		return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive")
-	default:
-		return t.ItemOfflineIcon, ""
-	}
-}
-
-// RenderLSPBlock renders a complete LSP block with optional truncation indicator.
-func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string {
-	t := styles.CurrentTheme()
-	lspList := RenderLSPList(lspClients, opts)
-
-	// Add truncation indicator if needed
-	if showTruncationIndicator && opts.MaxItems > 0 {
-		lspConfigs := config.Get().LSP.Sorted()
-		if len(lspConfigs) > opts.MaxItems {
-			remaining := len(lspConfigs) - opts.MaxItems
-			if remaining == 1 {
-				lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
-			} else {
-				lspList = append(lspList,
-					t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
-				)
-			}
-		}
-	}
-
-	content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
-	if opts.MaxWidth > 0 {
-		return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
-	}
-	return content
-}

internal/tui/components/mcp/mcp.go 🔗

@@ -1,138 +0,0 @@
-package mcp
-
-import (
-	"fmt"
-	"strings"
-
-	"charm.land/lipgloss/v2"
-
-	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-// RenderOptions contains options for rendering MCP lists.
-type RenderOptions struct {
-	MaxWidth    int
-	MaxItems    int
-	ShowSection bool
-	SectionName string
-}
-
-// RenderMCPList renders a list of MCP status items with the given options.
-func RenderMCPList(opts RenderOptions) []string {
-	t := styles.CurrentTheme()
-	mcpList := []string{}
-
-	if opts.ShowSection {
-		sectionName := opts.SectionName
-		if sectionName == "" {
-			sectionName = "MCPs"
-		}
-		section := t.S().Subtle.Render(sectionName)
-		mcpList = append(mcpList, section, "")
-	}
-
-	mcps := config.Get().MCP.Sorted()
-	if len(mcps) == 0 {
-		mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None"))
-		return mcpList
-	}
-
-	// Get MCP states
-	mcpStates := mcp.GetStates()
-
-	// Determine how many items to show
-	maxItems := len(mcps)
-	if opts.MaxItems > 0 {
-		maxItems = min(opts.MaxItems, len(mcps))
-	}
-
-	for i, l := range mcps {
-		if i >= maxItems {
-			break
-		}
-
-		// Determine icon and color based on state
-		icon := t.ItemOfflineIcon
-		description := ""
-		extraContent := []string{}
-
-		if state, exists := mcpStates[l.Name]; exists {
-			switch state.State {
-			case mcp.StateDisabled:
-				description = t.S().Subtle.Render("disabled")
-			case mcp.StateStarting:
-				icon = t.ItemBusyIcon
-				description = t.S().Subtle.Render("starting...")
-			case mcp.StateConnected:
-				icon = t.ItemOnlineIcon
-				if count := state.Counts.Tools; count > 0 {
-					label := "tools"
-					if count == 1 {
-						label = "tool"
-					}
-					extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label)))
-				}
-				if count := state.Counts.Prompts; count > 0 {
-					label := "prompts"
-					if count == 1 {
-						label = "prompt"
-					}
-					extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label)))
-				}
-			case mcp.StateError:
-				icon = t.ItemErrorIcon
-				if state.Error != nil {
-					description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
-				} else {
-					description = t.S().Subtle.Render("error")
-				}
-			}
-		} else if l.MCP.Disabled {
-			description = t.S().Subtle.Render("disabled")
-		}
-
-		mcpList = append(mcpList,
-			core.Status(
-				core.StatusOpts{
-					Icon:         icon.String(),
-					Title:        l.Name,
-					Description:  description,
-					ExtraContent: strings.Join(extraContent, " "),
-				},
-				opts.MaxWidth,
-			),
-		)
-	}
-
-	return mcpList
-}
-
-// RenderMCPBlock renders a complete MCP block with optional truncation indicator.
-func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string {
-	t := styles.CurrentTheme()
-	mcpList := RenderMCPList(opts)
-
-	// Add truncation indicator if needed
-	if showTruncationIndicator && opts.MaxItems > 0 {
-		mcps := config.Get().MCP.Sorted()
-		if len(mcps) > opts.MaxItems {
-			remaining := len(mcps) - opts.MaxItems
-			if remaining == 1 {
-				mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
-			} else {
-				mcpList = append(mcpList,
-					t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
-				)
-			}
-		}
-	}
-
-	content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
-	if opts.MaxWidth > 0 {
-		return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
-	}
-	return content
-}

internal/tui/exp/list/filterable.go 🔗

@@ -1,329 +0,0 @@
-package list
-
-import (
-	"regexp"
-	"slices"
-
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/textinput"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/sahilm/fuzzy"
-)
-
-// Pre-compiled regex for checking if a string is alphanumeric.
-var alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
-
-type FilterableItem interface {
-	Item
-	FilterValue() string
-}
-
-type FilterableList[T FilterableItem] interface {
-	List[T]
-	Cursor() *tea.Cursor
-	SetInputWidth(int)
-	SetInputPlaceholder(string)
-	SetResultsSize(int)
-	Filter(q string) tea.Cmd
-	fuzzy.Source
-}
-
-type HasMatchIndexes interface {
-	MatchIndexes([]int)
-}
-
-type filterableOptions struct {
-	listOptions []ListOption
-	placeholder string
-	inputHidden bool
-	inputWidth  int
-	inputStyle  lipgloss.Style
-}
-type filterableList[T FilterableItem] struct {
-	*list[T]
-	*filterableOptions
-	width, height int
-	// stores all available items
-	items       []T
-	resultsSize int
-	input       textinput.Model
-	inputWidth  int
-	query       string
-}
-
-type filterableListOption func(*filterableOptions)
-
-func WithFilterPlaceholder(ph string) filterableListOption {
-	return func(f *filterableOptions) {
-		f.placeholder = ph
-	}
-}
-
-func WithFilterInputHidden() filterableListOption {
-	return func(f *filterableOptions) {
-		f.inputHidden = true
-	}
-}
-
-func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
-	return func(f *filterableOptions) {
-		f.inputStyle = inputStyle
-	}
-}
-
-func WithFilterListOptions(opts ...ListOption) filterableListOption {
-	return func(f *filterableOptions) {
-		f.listOptions = opts
-	}
-}
-
-func WithFilterInputWidth(inputWidth int) filterableListOption {
-	return func(f *filterableOptions) {
-		f.inputWidth = inputWidth
-	}
-}
-
-func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] {
-	t := styles.CurrentTheme()
-
-	f := &filterableList[T]{
-		filterableOptions: &filterableOptions{
-			inputStyle:  t.S().Base,
-			placeholder: "Type to filter",
-		},
-	}
-	for _, opt := range opts {
-		opt(f.filterableOptions)
-	}
-	f.list = New(items, f.listOptions...).(*list[T])
-
-	f.updateKeyMaps()
-	f.items = f.list.items
-
-	if f.inputHidden {
-		return f
-	}
-
-	ti := textinput.New()
-	ti.Placeholder = f.placeholder
-	ti.SetVirtualCursor(false)
-	ti.Focus()
-	ti.SetStyles(t.S().TextInput)
-	f.input = ti
-	return f
-}
-
-func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyPressMsg:
-		switch {
-		// handle movements
-		case key.Matches(msg, f.keyMap.Down),
-			key.Matches(msg, f.keyMap.Up),
-			key.Matches(msg, f.keyMap.DownOneItem),
-			key.Matches(msg, f.keyMap.UpOneItem),
-			key.Matches(msg, f.keyMap.HalfPageDown),
-			key.Matches(msg, f.keyMap.HalfPageUp),
-			key.Matches(msg, f.keyMap.PageDown),
-			key.Matches(msg, f.keyMap.PageUp),
-			key.Matches(msg, f.keyMap.End),
-			key.Matches(msg, f.keyMap.Home):
-			u, cmd := f.list.Update(msg)
-			f.list = u.(*list[T])
-			return f, cmd
-		default:
-			if !f.inputHidden {
-				var cmds []tea.Cmd
-				var cmd tea.Cmd
-				f.input, cmd = f.input.Update(msg)
-				cmds = append(cmds, cmd)
-
-				if f.query != f.input.Value() {
-					cmd = f.Filter(f.input.Value())
-					cmds = append(cmds, cmd)
-				}
-				f.query = f.input.Value()
-				return f, tea.Batch(cmds...)
-			}
-		}
-	}
-	u, cmd := f.list.Update(msg)
-	f.list = u.(*list[T])
-	return f, cmd
-}
-
-func (f *filterableList[T]) View() string {
-	if f.inputHidden {
-		return f.list.View()
-	}
-
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		f.inputStyle.Render(f.input.View()),
-		f.list.View(),
-	)
-}
-
-// removes bindings that are used for search
-func (f *filterableList[T]) updateKeyMaps() {
-	removeLettersAndNumbers := func(bindings []string) []string {
-		var keep []string
-		for _, b := range bindings {
-			if len(b) != 1 {
-				keep = append(keep, b)
-				continue
-			}
-			if b == " " {
-				continue
-			}
-			m := alphanumericRegex.MatchString(b)
-			if !m {
-				keep = append(keep, b)
-			}
-		}
-		return keep
-	}
-
-	updateBinding := func(binding key.Binding) key.Binding {
-		newKeys := removeLettersAndNumbers(binding.Keys())
-		if len(newKeys) == 0 {
-			binding.SetEnabled(false)
-			return binding
-		}
-		binding.SetKeys(newKeys...)
-		return binding
-	}
-
-	f.keyMap.Down = updateBinding(f.keyMap.Down)
-	f.keyMap.Up = updateBinding(f.keyMap.Up)
-	f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
-	f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
-	f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
-	f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
-	f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
-	f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
-	f.keyMap.End = updateBinding(f.keyMap.End)
-	f.keyMap.Home = updateBinding(f.keyMap.Home)
-}
-
-func (m *filterableList[T]) GetSize() (int, int) {
-	return m.width, m.height
-}
-
-func (f *filterableList[T]) SetSize(w, h int) tea.Cmd {
-	f.width = w
-	f.height = h
-	if f.inputHidden {
-		return f.list.SetSize(w, h)
-	}
-	if f.inputWidth == 0 {
-		f.input.SetWidth(w)
-	} else {
-		f.input.SetWidth(f.inputWidth)
-	}
-	return f.list.SetSize(w, h-(f.inputHeight()))
-}
-
-func (f *filterableList[T]) inputHeight() int {
-	return lipgloss.Height(f.inputStyle.Render(f.input.View()))
-}
-
-func (f *filterableList[T]) Filter(query string) tea.Cmd {
-	var cmds []tea.Cmd
-	for _, item := range f.items {
-		if i, ok := any(item).(layout.Focusable); ok {
-			cmds = append(cmds, i.Blur())
-		}
-		if i, ok := any(item).(HasMatchIndexes); ok {
-			i.MatchIndexes(make([]int, 0))
-		}
-	}
-
-	f.selectedItemIdx = -1
-	if query == "" || len(f.items) == 0 {
-		return f.list.SetItems(f.visibleItems(f.items))
-	}
-
-	matches := fuzzy.FindFrom(query, f)
-
-	var matchedItems []T
-	resultSize := len(matches)
-	if f.resultsSize > 0 && resultSize > f.resultsSize {
-		resultSize = f.resultsSize
-	}
-	for i := range resultSize {
-		match := matches[i]
-		item := f.items[match.Index]
-		if it, ok := any(item).(HasMatchIndexes); ok {
-			it.MatchIndexes(match.MatchedIndexes)
-		}
-		matchedItems = append(matchedItems, item)
-	}
-
-	if f.direction == DirectionBackward {
-		slices.Reverse(matchedItems)
-	}
-
-	cmds = append(cmds, f.list.SetItems(matchedItems))
-	return tea.Batch(cmds...)
-}
-
-func (f *filterableList[T]) SetItems(items []T) tea.Cmd {
-	f.items = items
-	return f.list.SetItems(f.visibleItems(items))
-}
-
-func (f *filterableList[T]) Cursor() *tea.Cursor {
-	if f.inputHidden {
-		return nil
-	}
-	return f.input.Cursor()
-}
-
-func (f *filterableList[T]) Blur() tea.Cmd {
-	f.input.Blur()
-	return f.list.Blur()
-}
-
-func (f *filterableList[T]) Focus() tea.Cmd {
-	f.input.Focus()
-	return f.list.Focus()
-}
-
-func (f *filterableList[T]) IsFocused() bool {
-	return f.list.IsFocused()
-}
-
-func (f *filterableList[T]) SetInputWidth(w int) {
-	f.inputWidth = w
-}
-
-func (f *filterableList[T]) SetInputPlaceholder(ph string) {
-	f.placeholder = ph
-}
-
-func (f *filterableList[T]) SetResultsSize(size int) {
-	f.resultsSize = size
-}
-
-func (f *filterableList[T]) String(i int) string {
-	return f.items[i].FilterValue()
-}
-
-func (f *filterableList[T]) Len() int {
-	return len(f.items)
-}
-
-// visibleItems returns the subset of items that should be rendered based on
-// the configured resultsSize limit. The underlying source (f.items) remains
-// intact so filtering still searches the full set.
-func (f *filterableList[T]) visibleItems(items []T) []T {
-	if f.resultsSize > 0 && len(items) > f.resultsSize {
-		return items[:f.resultsSize]
-	}
-	return items
-}

internal/tui/exp/list/filterable_group.go 🔗

@@ -1,315 +0,0 @@
-package list
-
-import (
-	"regexp"
-	"sort"
-	"strings"
-
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/textinput"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/sahilm/fuzzy"
-)
-
-// Pre-compiled regex for checking if a string is alphanumeric.
-// Note: This is duplicated from filterable.go to avoid circular dependencies.
-var alphanumericRegexGroup = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
-
-type FilterableGroupList[T FilterableItem] interface {
-	GroupedList[T]
-	Cursor() *tea.Cursor
-	SetInputWidth(int)
-	SetInputPlaceholder(string)
-}
-type filterableGroupList[T FilterableItem] struct {
-	*groupedList[T]
-	*filterableOptions
-	width, height int
-	groups        []Group[T]
-	// stores all available items
-	input      textinput.Model
-	inputWidth int
-	query      string
-}
-
-func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
-	t := styles.CurrentTheme()
-
-	f := &filterableGroupList[T]{
-		filterableOptions: &filterableOptions{
-			inputStyle:  t.S().Base,
-			placeholder: "Type to filter",
-		},
-	}
-	for _, opt := range opts {
-		opt(f.filterableOptions)
-	}
-	f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
-
-	f.updateKeyMaps()
-
-	if f.inputHidden {
-		return f
-	}
-
-	ti := textinput.New()
-	ti.Placeholder = f.placeholder
-	ti.SetVirtualCursor(false)
-	ti.Focus()
-	ti.SetStyles(t.S().TextInput)
-	f.input = ti
-	return f
-}
-
-func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyPressMsg:
-		switch {
-		// handle movements
-		case key.Matches(msg, f.keyMap.Down),
-			key.Matches(msg, f.keyMap.Up),
-			key.Matches(msg, f.keyMap.DownOneItem),
-			key.Matches(msg, f.keyMap.UpOneItem),
-			key.Matches(msg, f.keyMap.HalfPageDown),
-			key.Matches(msg, f.keyMap.HalfPageUp),
-			key.Matches(msg, f.keyMap.PageDown),
-			key.Matches(msg, f.keyMap.PageUp),
-			key.Matches(msg, f.keyMap.End),
-			key.Matches(msg, f.keyMap.Home):
-			u, cmd := f.groupedList.Update(msg)
-			f.groupedList = u.(*groupedList[T])
-			return f, cmd
-		default:
-			if !f.inputHidden {
-				var cmds []tea.Cmd
-				var cmd tea.Cmd
-				f.input, cmd = f.input.Update(msg)
-				cmds = append(cmds, cmd)
-
-				if f.query != f.input.Value() {
-					cmd = f.Filter(f.input.Value())
-					cmds = append(cmds, cmd)
-				}
-				f.query = f.input.Value()
-				return f, tea.Batch(cmds...)
-			}
-		}
-	}
-	u, cmd := f.groupedList.Update(msg)
-	f.groupedList = u.(*groupedList[T])
-	return f, cmd
-}
-
-func (f *filterableGroupList[T]) View() string {
-	if f.inputHidden {
-		return f.groupedList.View()
-	}
-
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		f.inputStyle.Render(f.input.View()),
-		f.groupedList.View(),
-	)
-}
-
-// removes bindings that are used for search
-func (f *filterableGroupList[T]) updateKeyMaps() {
-	removeLettersAndNumbers := func(bindings []string) []string {
-		var keep []string
-		for _, b := range bindings {
-			if len(b) != 1 {
-				keep = append(keep, b)
-				continue
-			}
-			if b == " " {
-				continue
-			}
-			m := alphanumericRegexGroup.MatchString(b)
-			if !m {
-				keep = append(keep, b)
-			}
-		}
-		return keep
-	}
-
-	updateBinding := func(binding key.Binding) key.Binding {
-		newKeys := removeLettersAndNumbers(binding.Keys())
-		if len(newKeys) == 0 {
-			binding.SetEnabled(false)
-			return binding
-		}
-		binding.SetKeys(newKeys...)
-		return binding
-	}
-
-	f.keyMap.Down = updateBinding(f.keyMap.Down)
-	f.keyMap.Up = updateBinding(f.keyMap.Up)
-	f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
-	f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
-	f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
-	f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
-	f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
-	f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
-	f.keyMap.End = updateBinding(f.keyMap.End)
-	f.keyMap.Home = updateBinding(f.keyMap.Home)
-}
-
-func (m *filterableGroupList[T]) GetSize() (int, int) {
-	return m.width, m.height
-}
-
-func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
-	f.width = w
-	f.height = h
-	if f.inputHidden {
-		return f.groupedList.SetSize(w, h)
-	}
-	if f.inputWidth == 0 {
-		f.input.SetWidth(w)
-	} else {
-		f.input.SetWidth(f.inputWidth)
-	}
-	return f.groupedList.SetSize(w, h-(f.inputHeight()))
-}
-
-func (f *filterableGroupList[T]) inputHeight() int {
-	return lipgloss.Height(f.inputStyle.Render(f.input.View()))
-}
-
-func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
-	var cmds []tea.Cmd
-	for _, item := range f.items {
-		if i, ok := any(item).(layout.Focusable); ok {
-			cmds = append(cmds, i.Blur())
-		}
-		if i, ok := any(item).(HasMatchIndexes); ok {
-			i.MatchIndexes(make([]int, 0))
-		}
-	}
-	return cmds
-}
-
-func (f *filterableGroupList[T]) getGroupName(g Group[T]) string {
-	if section, ok := g.Section.(*itemSectionModel); ok {
-		return strings.ToLower(section.title)
-	}
-	return strings.ToLower(g.Section.ID())
-}
-
-func (f *filterableGroupList[T]) setMatchIndexes(item T, indexes []int) {
-	if i, ok := any(item).(HasMatchIndexes); ok {
-		i.MatchIndexes(indexes)
-	}
-}
-
-func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string) []T {
-	if query == "" {
-		// No query, return all items with cleared match indexes
-		var items []T
-		for _, item := range group.Items {
-			f.setMatchIndexes(item, make([]int, 0))
-			items = append(items, item)
-		}
-		return items
-	}
-
-	name := f.getGroupName(group) + " "
-
-	names := make([]string, len(group.Items))
-	for i, item := range group.Items {
-		names[i] = strings.ToLower(name + item.FilterValue())
-	}
-
-	matches := fuzzy.Find(query, names)
-	sort.SliceStable(matches, func(i, j int) bool {
-		return matches[i].Score > matches[j].Score
-	})
-
-	if len(matches) > 0 {
-		var matchedItems []T
-		for _, match := range matches {
-			item := group.Items[match.Index]
-			var idxs []int
-			for _, idx := range match.MatchedIndexes {
-				// adjusts removing group name highlights
-				if idx < len(name) {
-					continue
-				}
-				idxs = append(idxs, idx-len(name))
-			}
-			f.setMatchIndexes(item, idxs)
-			matchedItems = append(matchedItems, item)
-		}
-		return matchedItems
-	}
-
-	return []T{}
-}
-
-func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
-	cmds := f.clearItemState()
-	f.selectedItemIdx = -1
-
-	if query == "" {
-		return f.groupedList.SetGroups(f.groups)
-	}
-
-	query = strings.ToLower(strings.ReplaceAll(query, " ", ""))
-
-	var result []Group[T]
-	for _, g := range f.groups {
-		if matches := fuzzy.Find(query, []string{f.getGroupName(g)}); len(matches) > 0 && matches[0].Score > 0 {
-			result = append(result, g)
-			continue
-		}
-		matchedItems := f.filterItemsInGroup(g, query)
-		if len(matchedItems) > 0 {
-			result = append(result, Group[T]{
-				Section: g.Section,
-				Items:   matchedItems,
-			})
-		}
-	}
-
-	cmds = append(cmds, f.groupedList.SetGroups(result))
-	return tea.Batch(cmds...)
-}
-
-func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
-	f.groups = groups
-	return f.groupedList.SetGroups(groups)
-}
-
-func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
-	if f.inputHidden {
-		return nil
-	}
-	return f.input.Cursor()
-}
-
-func (f *filterableGroupList[T]) Blur() tea.Cmd {
-	f.input.Blur()
-	return f.groupedList.Blur()
-}
-
-func (f *filterableGroupList[T]) Focus() tea.Cmd {
-	f.input.Focus()
-	return f.groupedList.Focus()
-}
-
-func (f *filterableGroupList[T]) IsFocused() bool {
-	return f.groupedList.IsFocused()
-}
-
-func (f *filterableGroupList[T]) SetInputWidth(w int) {
-	f.inputWidth = w
-}
-
-func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
-	f.input.Placeholder = ph
-	f.placeholder = ph
-}

internal/tui/exp/list/filterable_test.go 🔗

@@ -1,68 +0,0 @@
-package list
-
-import (
-	"fmt"
-	"slices"
-	"testing"
-
-	"github.com/charmbracelet/x/exp/golden"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestFilterableList(t *testing.T) {
-	t.Parallel()
-	t.Run("should create simple filterable list", func(t *testing.T) {
-		t.Parallel()
-		items := []FilterableItem{}
-		for i := range 5 {
-			item := NewFilterableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := NewFilterableList(
-			items,
-			WithFilterListOptions(WithDirectionForward()),
-		).(*filterableList[FilterableItem])
-
-		l.SetSize(100, 10)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
-
-		assert.Equal(t, 0, l.selectedItemIdx)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-}
-
-func TestUpdateKeyMap(t *testing.T) {
-	t.Parallel()
-	l := NewFilterableList(
-		[]FilterableItem{},
-		WithFilterListOptions(WithDirectionForward()),
-	).(*filterableList[FilterableItem])
-
-	hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
-	fmt.Println(l.keyMap.Down.Keys())
-	hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
-
-	hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
-
-	assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
-	assert.False(t, hasJ, "should not contain j")
-	assert.False(t, hasUpperCaseK, "should also remove upper case K")
-	assert.True(t, hasCtrlJ, "should still have ctrl+j")
-}
-
-type filterableItem struct {
-	*selectableItem
-}
-
-func NewFilterableItem(content string) FilterableItem {
-	return &filterableItem{
-		selectableItem: NewSelectableItem(content).(*selectableItem),
-	}
-}
-
-func (f *filterableItem) FilterValue() string {
-	return f.content
-}

internal/tui/exp/list/grouped.go 🔗

@@ -1,100 +0,0 @@
-package list
-
-import (
-	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type Group[T Item] struct {
-	Section ItemSection
-	Items   []T
-}
-type GroupedList[T Item] interface {
-	util.Model
-	layout.Sizeable
-	Items() []Item
-	Groups() []Group[T]
-	SetGroups([]Group[T]) tea.Cmd
-	MoveUp(int) tea.Cmd
-	MoveDown(int) tea.Cmd
-	GoToTop() tea.Cmd
-	GoToBottom() tea.Cmd
-	SelectItemAbove() tea.Cmd
-	SelectItemBelow() tea.Cmd
-	SetSelected(string) tea.Cmd
-	SelectedItem() *T
-}
-type groupedList[T Item] struct {
-	*list[Item]
-	groups []Group[T]
-}
-
-func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
-	list := &list[Item]{
-		confOptions: &confOptions{
-			direction: DirectionForward,
-			keyMap:    DefaultKeyMap(),
-			focused:   true,
-		},
-		items:         []Item{},
-		indexMap:      make(map[string]int),
-		renderedItems: make(map[string]renderedItem),
-	}
-	for _, opt := range opts {
-		opt(list.confOptions)
-	}
-
-	return &groupedList[T]{
-		list: list,
-	}
-}
-
-func (g *groupedList[T]) Init() tea.Cmd {
-	g.convertItems()
-	return g.render()
-}
-
-func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	u, cmd := l.list.Update(msg)
-	l.list = u.(*list[Item])
-	return l, cmd
-}
-
-func (g *groupedList[T]) SelectedItem() *T {
-	item := g.list.SelectedItem()
-	if item == nil {
-		return nil
-	}
-	dRef := *item
-	c, ok := any(dRef).(T)
-	if !ok {
-		return nil
-	}
-	return &c
-}
-
-func (g *groupedList[T]) convertItems() {
-	var items []Item
-	for _, g := range g.groups {
-		items = append(items, g.Section)
-		for _, g := range g.Items {
-			items = append(items, g)
-		}
-	}
-	g.items = items
-}
-
-func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
-	g.groups = groups
-	g.convertItems()
-	return g.SetItems(g.items)
-}
-
-func (g *groupedList[T]) Groups() []Group[T] {
-	return g.groups
-}
-
-func (g *groupedList[T]) Items() []Item {
-	return g.list.Items()
-}

internal/tui/exp/list/items.go 🔗

@@ -1,399 +0,0 @@
-package list
-
-import (
-	"image/color"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/google/uuid"
-	"github.com/rivo/uniseg"
-)
-
-type Indexable interface {
-	SetIndex(int)
-}
-
-type CompletionItem[T any] interface {
-	FilterableItem
-	layout.Focusable
-	layout.Sizeable
-	HasMatchIndexes
-	Value() T
-	Text() string
-}
-
-type completionItemCmp[T any] struct {
-	width        int
-	id           string
-	text         string
-	value        T
-	focus        bool
-	matchIndexes []int
-	bgColor      color.Color
-	shortcut     string
-}
-
-type options struct {
-	id           string
-	text         string
-	bgColor      color.Color
-	matchIndexes []int
-	shortcut     string
-}
-
-type CompletionItemOption func(*options)
-
-func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
-	return func(cmp *options) {
-		cmp.bgColor = c
-	}
-}
-
-func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
-	return func(cmp *options) {
-		cmp.matchIndexes = indexes
-	}
-}
-
-func WithCompletionShortcut(shortcut string) CompletionItemOption {
-	return func(cmp *options) {
-		cmp.shortcut = shortcut
-	}
-}
-
-func WithCompletionID(id string) CompletionItemOption {
-	return func(cmp *options) {
-		cmp.id = id
-	}
-}
-
-func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
-	c := &completionItemCmp[T]{
-		text:  text,
-		value: value,
-	}
-	o := &options{}
-
-	for _, opt := range opts {
-		opt(o)
-	}
-	if o.id == "" {
-		o.id = uuid.NewString()
-	}
-	c.id = o.id
-	c.bgColor = o.bgColor
-	c.matchIndexes = o.matchIndexes
-	c.shortcut = o.shortcut
-	return c
-}
-
-// Init implements CommandItem.
-func (c *completionItemCmp[T]) Init() tea.Cmd {
-	return nil
-}
-
-// Update implements CommandItem.
-func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
-	return c, nil
-}
-
-// View implements CommandItem.
-func (c *completionItemCmp[T]) View() string {
-	t := styles.CurrentTheme()
-
-	itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
-	innerWidth := c.width - 2 // Account for padding
-
-	if c.shortcut != "" {
-		innerWidth -= lipgloss.Width(c.shortcut)
-	}
-
-	titleStyle := t.S().Text.Width(innerWidth)
-	titleMatchStyle := t.S().Text.Underline(true)
-	if c.bgColor != nil {
-		titleStyle = titleStyle.Background(c.bgColor)
-		titleMatchStyle = titleMatchStyle.Background(c.bgColor)
-		itemStyle = itemStyle.Background(c.bgColor)
-	}
-
-	if c.focus {
-		titleStyle = t.S().TextSelected.Width(innerWidth)
-		titleMatchStyle = t.S().TextSelected.Underline(true)
-		itemStyle = itemStyle.Background(t.Primary)
-	}
-
-	var truncatedTitle string
-
-	if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
-		// Smart truncation: ensure the last matching part is visible
-		truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
-	} else {
-		// No matches, use regular truncation
-		truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
-	}
-
-	text := titleStyle.Render(truncatedTitle)
-	if len(c.matchIndexes) > 0 {
-		var ranges []lipgloss.Range
-		for _, rng := range matchedRanges(c.matchIndexes) {
-			// ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
-			// all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
-			// so we need to adjust it here:
-			start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
-			ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
-		}
-		text = lipgloss.StyleRanges(text, ranges...)
-	}
-	parts := []string{text}
-	if c.shortcut != "" {
-		// Add the shortcut at the end
-		shortcutStyle := t.S().Muted
-		if c.focus {
-			shortcutStyle = t.S().TextSelected
-		}
-		parts = append(parts, shortcutStyle.Render(c.shortcut))
-	}
-	item := itemStyle.Render(
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			parts...,
-		),
-	)
-	return item
-}
-
-// Blur implements CommandItem.
-func (c *completionItemCmp[T]) Blur() tea.Cmd {
-	c.focus = false
-	return nil
-}
-
-// Focus implements CommandItem.
-func (c *completionItemCmp[T]) Focus() tea.Cmd {
-	c.focus = true
-	return nil
-}
-
-// GetSize implements CommandItem.
-func (c *completionItemCmp[T]) GetSize() (int, int) {
-	return c.width, 1
-}
-
-// IsFocused implements CommandItem.
-func (c *completionItemCmp[T]) IsFocused() bool {
-	return c.focus
-}
-
-// SetSize implements CommandItem.
-func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
-	c.width = width
-	return nil
-}
-
-func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
-	c.matchIndexes = indexes
-}
-
-func (c *completionItemCmp[T]) FilterValue() string {
-	return c.text
-}
-
-func (c *completionItemCmp[T]) Value() T {
-	return c.value
-}
-
-// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
-func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
-	if width <= 0 {
-		return ""
-	}
-
-	textLen := ansi.StringWidth(text)
-	if textLen <= width {
-		return text
-	}
-
-	if len(matchIndexes) == 0 {
-		return ansi.Truncate(text, width, "…")
-	}
-
-	// Find the last match position
-	lastMatchPos := matchIndexes[len(matchIndexes)-1]
-
-	// Convert byte position to visual width position
-	lastMatchVisualPos := 0
-	bytePos := 0
-	gr := uniseg.NewGraphemes(text)
-	for bytePos < lastMatchPos && gr.Next() {
-		bytePos += len(gr.Str())
-		lastMatchVisualPos += max(1, gr.Width())
-	}
-
-	// Calculate how much space we need for the ellipsis
-	ellipsisWidth := 1 // "…" character width
-	availableWidth := width - ellipsisWidth
-
-	// If the last match is within the available width, truncate from the end
-	if lastMatchVisualPos < availableWidth {
-		return ansi.Truncate(text, width, "…")
-	}
-
-	// Calculate the start position to ensure the last match is visible
-	// We want to show some context before the last match if possible
-	startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
-
-	// Convert visual position back to byte position
-	startBytePos := 0
-	currentVisualPos := 0
-	gr = uniseg.NewGraphemes(text)
-	for currentVisualPos < startVisualPos && gr.Next() {
-		startBytePos += len(gr.Str())
-		currentVisualPos += max(1, gr.Width())
-	}
-
-	// Extract the substring starting from startBytePos
-	truncatedText := text[startBytePos:]
-
-	// Truncate to fit width with ellipsis
-	truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
-	truncatedText = "…" + truncatedText
-	return truncatedText
-}
-
-func matchedRanges(in []int) [][2]int {
-	if len(in) == 0 {
-		return [][2]int{}
-	}
-	current := [2]int{in[0], in[0]}
-	if len(in) == 1 {
-		return [][2]int{current}
-	}
-	var out [][2]int
-	for i := 1; i < len(in); i++ {
-		if in[i] == current[1]+1 {
-			current[1] = in[i]
-		} else {
-			out = append(out, current)
-			current = [2]int{in[i], in[i]}
-		}
-	}
-	out = append(out, current)
-	return out
-}
-
-func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
-	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
-	pos, start, stop := 0, 0, 0
-	gr := uniseg.NewGraphemes(str)
-	for byteStart > bytePos {
-		if !gr.Next() {
-			break
-		}
-		bytePos += len(gr.Str())
-		pos += max(1, gr.Width())
-	}
-	start = pos
-	for byteStop > bytePos {
-		if !gr.Next() {
-			break
-		}
-		bytePos += len(gr.Str())
-		pos += max(1, gr.Width())
-	}
-	stop = pos
-	return start, stop
-}
-
-// ID implements CompletionItem.
-func (c *completionItemCmp[T]) ID() string {
-	return c.id
-}
-
-func (c *completionItemCmp[T]) Text() string {
-	return c.text
-}
-
-type ItemSection interface {
-	Item
-	layout.Sizeable
-	Indexable
-	SetInfo(info string)
-	Title() string
-}
-type itemSectionModel struct {
-	width int
-	title string
-	inx   int
-	id    string
-	info  string
-}
-
-// ID implements ItemSection.
-func (m *itemSectionModel) ID() string {
-	return m.id
-}
-
-// Title implements ItemSection.
-func (m *itemSectionModel) Title() string {
-	return m.title
-}
-
-func NewItemSection(title string) ItemSection {
-	return &itemSectionModel{
-		title: title,
-		inx:   -1,
-		id:    uuid.NewString(),
-	}
-}
-
-func (m *itemSectionModel) Init() tea.Cmd {
-	return nil
-}
-
-func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
-	return m, nil
-}
-
-func (m *itemSectionModel) View() string {
-	t := styles.CurrentTheme()
-	title := ansi.Truncate(m.title, m.width-2, "…")
-	style := t.S().Base.Padding(1, 1, 0, 1)
-	if m.inx == 0 {
-		style = style.Padding(0, 1, 0, 1)
-	}
-	title = t.S().Muted.Render(title)
-	section := ""
-	if m.info != "" {
-		section = core.SectionWithInfo(title, m.width-2, m.info)
-	} else {
-		section = core.Section(title, m.width-2)
-	}
-
-	return style.Render(section)
-}
-
-func (m *itemSectionModel) GetSize() (int, int) {
-	return m.width, 1
-}
-
-func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
-	m.width = width
-	return nil
-}
-
-func (m *itemSectionModel) IsSectionHeader() bool {
-	return true
-}
-
-func (m *itemSectionModel) SetInfo(info string) {
-	m.info = info
-}
-
-func (m *itemSectionModel) SetIndex(inx int) {
-	m.inx = inx
-}

internal/tui/exp/list/keys.go 🔗

@@ -1,76 +0,0 @@
-package list
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	Down,
-	Up,
-	DownOneItem,
-	UpOneItem,
-	PageDown,
-	PageUp,
-	HalfPageDown,
-	HalfPageUp,
-	Home,
-	End key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Down: key.NewBinding(
-			key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
-			key.WithHelp("↓", "down"),
-		),
-		Up: key.NewBinding(
-			key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
-			key.WithHelp("↑", "up"),
-		),
-		UpOneItem: key.NewBinding(
-			key.WithKeys("shift+up", "K"),
-			key.WithHelp("shift+↑", "up one item"),
-		),
-		DownOneItem: key.NewBinding(
-			key.WithKeys("shift+down", "J"),
-			key.WithHelp("shift+↓", "down one item"),
-		),
-		HalfPageDown: key.NewBinding(
-			key.WithKeys("d"),
-			key.WithHelp("d", "half page down"),
-		),
-		PageDown: key.NewBinding(
-			key.WithKeys("pgdown", " ", "f"),
-			key.WithHelp("f/pgdn", "page down"),
-		),
-		PageUp: key.NewBinding(
-			key.WithKeys("pgup", "b"),
-			key.WithHelp("b/pgup", "page up"),
-		),
-		HalfPageUp: key.NewBinding(
-			key.WithKeys("u"),
-			key.WithHelp("u", "half page up"),
-		),
-		Home: key.NewBinding(
-			key.WithKeys("g", "home"),
-			key.WithHelp("g", "home"),
-		),
-		End: key.NewBinding(
-			key.WithKeys("G", "end"),
-			key.WithHelp("G", "end"),
-		),
-	}
-}
-
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Down,
-		k.Up,
-		k.DownOneItem,
-		k.UpOneItem,
-		k.HalfPageDown,
-		k.HalfPageUp,
-		k.Home,
-		k.End,
-	}
-}

internal/tui/exp/list/list.go 🔗

@@ -1,1775 +0,0 @@
-package list
-
-import (
-	"strings"
-	"sync"
-
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/anim"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	uv "github.com/charmbracelet/ultraviolet"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/charmbracelet/x/exp/ordered"
-	"github.com/rivo/uniseg"
-)
-
-const maxGapSize = 100
-
-var newlineBuffer = strings.Repeat("\n", maxGapSize)
-
-var (
-	specialCharsMap  map[string]struct{}
-	specialCharsOnce sync.Once
-)
-
-func getSpecialCharsMap() map[string]struct{} {
-	specialCharsOnce.Do(func() {
-		specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons))
-		for _, icon := range styles.SelectionIgnoreIcons {
-			specialCharsMap[icon] = struct{}{}
-		}
-	})
-	return specialCharsMap
-}
-
-type Item interface {
-	util.Model
-	layout.Sizeable
-	ID() string
-}
-
-type HasAnim interface {
-	Item
-	Spinning() bool
-}
-
-type List[T Item] interface {
-	util.Model
-	layout.Sizeable
-	layout.Focusable
-
-	MoveUp(int) tea.Cmd
-	MoveDown(int) tea.Cmd
-	GoToTop() tea.Cmd
-	GoToBottom() tea.Cmd
-	SelectItemAbove() tea.Cmd
-	SelectItemBelow() tea.Cmd
-	SetItems([]T) tea.Cmd
-	SetSelected(string) tea.Cmd
-	SelectedItem() *T
-	Items() []T
-	UpdateItem(string, T) tea.Cmd
-	DeleteItem(string) tea.Cmd
-	PrependItem(T) tea.Cmd
-	AppendItem(T) tea.Cmd
-	StartSelection(col, line int)
-	EndSelection(col, line int)
-	SelectionStop()
-	SelectionClear()
-	SelectWord(col, line int)
-	SelectParagraph(col, line int)
-	GetSelectedText(paddingLeft int) string
-	HasSelection() bool
-}
-
-type direction int
-
-const (
-	DirectionForward direction = iota
-	DirectionBackward
-)
-
-const (
-	ItemNotFound              = -1
-	ViewportDefaultScrollSize = 5
-)
-
-type renderedItem struct {
-	view   string
-	height int
-	start  int
-	end    int
-}
-
-type confOptions struct {
-	width, height   int
-	gap             int
-	wrap            bool
-	keyMap          KeyMap
-	direction       direction
-	selectedItemIdx int    // Index of selected item (-1 if none)
-	selectedItemID  string // Temporary storage for WithSelectedItem (resolved in New())
-	focused         bool
-	resize          bool
-	enableMouse     bool
-}
-
-type list[T Item] struct {
-	*confOptions
-
-	offset int
-
-	indexMap      map[string]int
-	items         []T
-	renderedItems map[string]renderedItem
-
-	rendered       string
-	renderedHeight int   // cached height of rendered content
-	lineOffsets    []int // cached byte offsets for each line (for fast slicing)
-
-	cachedView       string
-	cachedViewOffset int
-	cachedViewDirty  bool
-
-	movingByItem        bool
-	prevSelectedItemIdx int // Index of previously selected item (-1 if none)
-	selectionStartCol   int
-	selectionStartLine  int
-	selectionEndCol     int
-	selectionEndLine    int
-
-	selectionActive bool
-}
-
-type ListOption func(*confOptions)
-
-// WithSize sets the size of the list.
-func WithSize(width, height int) ListOption {
-	return func(l *confOptions) {
-		l.width = width
-		l.height = height
-	}
-}
-
-// WithGap sets the gap between items in the list.
-func WithGap(gap int) ListOption {
-	return func(l *confOptions) {
-		l.gap = gap
-	}
-}
-
-// WithDirectionForward sets the direction to forward
-func WithDirectionForward() ListOption {
-	return func(l *confOptions) {
-		l.direction = DirectionForward
-	}
-}
-
-// WithDirectionBackward sets the direction to forward
-func WithDirectionBackward() ListOption {
-	return func(l *confOptions) {
-		l.direction = DirectionBackward
-	}
-}
-
-// WithSelectedItem sets the initially selected item in the list.
-func WithSelectedItem(id string) ListOption {
-	return func(l *confOptions) {
-		l.selectedItemID = id // Will be resolved to index in New()
-	}
-}
-
-func WithKeyMap(keyMap KeyMap) ListOption {
-	return func(l *confOptions) {
-		l.keyMap = keyMap
-	}
-}
-
-func WithWrapNavigation() ListOption {
-	return func(l *confOptions) {
-		l.wrap = true
-	}
-}
-
-func WithFocus(focus bool) ListOption {
-	return func(l *confOptions) {
-		l.focused = focus
-	}
-}
-
-func WithResizeByList() ListOption {
-	return func(l *confOptions) {
-		l.resize = true
-	}
-}
-
-func WithEnableMouse() ListOption {
-	return func(l *confOptions) {
-		l.enableMouse = true
-	}
-}
-
-func New[T Item](items []T, opts ...ListOption) List[T] {
-	list := &list[T]{
-		confOptions: &confOptions{
-			direction:       DirectionForward,
-			keyMap:          DefaultKeyMap(),
-			focused:         true,
-			selectedItemIdx: -1,
-		},
-		items:               items,
-		indexMap:            make(map[string]int, len(items)),
-		renderedItems:       make(map[string]renderedItem),
-		prevSelectedItemIdx: -1,
-		selectionStartCol:   -1,
-		selectionStartLine:  -1,
-		selectionEndLine:    -1,
-		selectionEndCol:     -1,
-	}
-	for _, opt := range opts {
-		opt(list.confOptions)
-	}
-
-	for inx, item := range items {
-		if i, ok := any(item).(Indexable); ok {
-			i.SetIndex(inx)
-		}
-		list.indexMap[item.ID()] = inx
-	}
-
-	// Resolve selectedItemID to selectedItemIdx if specified
-	if list.selectedItemID != "" {
-		if idx, ok := list.indexMap[list.selectedItemID]; ok {
-			list.selectedItemIdx = idx
-		}
-		list.selectedItemID = "" // Clear temporary storage
-	}
-
-	return list
-}
-
-// Init implements List.
-func (l *list[T]) Init() tea.Cmd {
-	return l.render()
-}
-
-// Update implements List.
-func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.MouseWheelMsg:
-		if l.enableMouse {
-			return l.handleMouseWheel(msg)
-		}
-		return l, nil
-	case anim.StepMsg:
-		// Fast path: if no items, skip processing
-		if len(l.items) == 0 {
-			return l, nil
-		}
-
-		// Fast path: check if ANY items are actually spinning before processing
-		if !l.hasSpinningItems() {
-			return l, nil
-		}
-
-		var cmds []tea.Cmd
-		itemsLen := len(l.items)
-		for i := range itemsLen {
-			if i >= len(l.items) {
-				continue
-			}
-			item := l.items[i]
-			if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
-				updated, cmd := animItem.Update(msg)
-				cmds = append(cmds, cmd)
-				if u, ok := updated.(T); ok {
-					cmds = append(cmds, l.UpdateItem(u.ID(), u))
-				}
-			}
-		}
-		return l, tea.Batch(cmds...)
-	case tea.KeyPressMsg:
-		if l.focused {
-			switch {
-			case key.Matches(msg, l.keyMap.Down):
-				return l, l.MoveDown(ViewportDefaultScrollSize)
-			case key.Matches(msg, l.keyMap.Up):
-				return l, l.MoveUp(ViewportDefaultScrollSize)
-			case key.Matches(msg, l.keyMap.DownOneItem):
-				return l, l.SelectItemBelow()
-			case key.Matches(msg, l.keyMap.UpOneItem):
-				return l, l.SelectItemAbove()
-			case key.Matches(msg, l.keyMap.HalfPageDown):
-				return l, l.MoveDown(l.height / 2)
-			case key.Matches(msg, l.keyMap.HalfPageUp):
-				return l, l.MoveUp(l.height / 2)
-			case key.Matches(msg, l.keyMap.PageDown):
-				return l, l.MoveDown(l.height)
-			case key.Matches(msg, l.keyMap.PageUp):
-				return l, l.MoveUp(l.height)
-			case key.Matches(msg, l.keyMap.End):
-				return l, l.GoToBottom()
-			case key.Matches(msg, l.keyMap.Home):
-				return l, l.GoToTop()
-			}
-			s := l.SelectedItem()
-			if s == nil {
-				return l, nil
-			}
-			item := *s
-			var cmds []tea.Cmd
-			updated, cmd := item.Update(msg)
-			cmds = append(cmds, cmd)
-			if u, ok := updated.(T); ok {
-				cmds = append(cmds, l.UpdateItem(u.ID(), u))
-			}
-			return l, tea.Batch(cmds...)
-		}
-	}
-	return l, nil
-}
-
-func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	switch msg.Button {
-	case tea.MouseWheelDown:
-		cmd = l.MoveDown(ViewportDefaultScrollSize)
-	case tea.MouseWheelUp:
-		cmd = l.MoveUp(ViewportDefaultScrollSize)
-	}
-	return l, cmd
-}
-
-func (l *list[T]) hasSpinningItems() bool {
-	for i := range l.items {
-		item := l.items[i]
-		if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
-			return true
-		}
-	}
-	return false
-}
-
-func (l *list[T]) selectionView(view string, textOnly bool) string {
-	t := styles.CurrentTheme()
-	area := uv.Rect(0, 0, l.width, l.height)
-	scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
-	uv.NewStyledString(view).Draw(scr, area)
-
-	selArea := l.selectionArea(false)
-	specialChars := getSpecialCharsMap()
-	selStyle := uv.Style{
-		Bg: t.TextSelection.GetBackground(),
-		Fg: t.TextSelection.GetForeground(),
-	}
-
-	isNonWhitespace := func(r rune) bool {
-		return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
-	}
-
-	type selectionBounds struct {
-		startX, endX int
-		inSelection  bool
-	}
-	lineSelections := make([]selectionBounds, scr.Height())
-
-	for y := range scr.Height() {
-		bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
-
-		if y >= selArea.Min.Y && y < selArea.Max.Y {
-			bounds.inSelection = true
-			if selArea.Min.Y == selArea.Max.Y-1 {
-				// Single line selection
-				bounds.startX = selArea.Min.X
-				bounds.endX = selArea.Max.X
-			} else if y == selArea.Min.Y {
-				// First line of multi-line selection
-				bounds.startX = selArea.Min.X
-				bounds.endX = scr.Width()
-			} else if y == selArea.Max.Y-1 {
-				// Last line of multi-line selection
-				bounds.startX = 0
-				bounds.endX = selArea.Max.X
-			} else {
-				// Middle lines
-				bounds.startX = 0
-				bounds.endX = scr.Width()
-			}
-		}
-		lineSelections[y] = bounds
-	}
-
-	type lineBounds struct {
-		start, end int
-	}
-	lineTextBounds := make([]lineBounds, scr.Height())
-
-	// First pass: find text bounds for lines that have selections
-	for y := range scr.Height() {
-		bounds := lineBounds{start: -1, end: -1}
-
-		// Only process lines that might have selections
-		if lineSelections[y].inSelection {
-			for x := range scr.Width() {
-				cell := scr.CellAt(x, y)
-				if cell == nil {
-					continue
-				}
-
-				cellStr := cell.String()
-				if len(cellStr) == 0 {
-					continue
-				}
-
-				char := rune(cellStr[0])
-				_, isSpecial := specialChars[cellStr]
-
-				if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
-					if bounds.start == -1 {
-						bounds.start = x
-					}
-					bounds.end = x + 1 // Position after last character
-				}
-			}
-		}
-		lineTextBounds[y] = bounds
-	}
-
-	var selectedText strings.Builder
-
-	// Second pass: apply selection highlighting
-	for y := range scr.Height() {
-		selBounds := lineSelections[y]
-		if !selBounds.inSelection {
-			continue
-		}
-
-		textBounds := lineTextBounds[y]
-		if textBounds.start < 0 {
-			if textOnly {
-				// We don't want to get rid of all empty lines in text-only mode
-				selectedText.WriteByte('\n')
-			}
-
-			continue // No text on this line
-		}
-
-		// Only scan within the intersection of text bounds and selection bounds
-		scanStart := max(textBounds.start, selBounds.startX)
-		scanEnd := min(textBounds.end, selBounds.endX)
-
-		for x := scanStart; x < scanEnd; x++ {
-			cell := scr.CellAt(x, y)
-			if cell == nil {
-				continue
-			}
-
-			cellStr := cell.String()
-			if len(cellStr) > 0 {
-				if _, isSpecial := specialChars[cellStr]; isSpecial {
-					continue
-				}
-				if textOnly {
-					// Collect selected text without styles
-					selectedText.WriteString(cell.String())
-					continue
-				}
-
-				cell = cell.Clone()
-				cell.Style.Bg = selStyle.Bg
-				cell.Style.Fg = selStyle.Fg
-				scr.SetCell(x, y, cell)
-			}
-		}
-
-		if textOnly {
-			// Make sure we add a newline after each line of selected text
-			selectedText.WriteByte('\n')
-		}
-	}
-
-	if textOnly {
-		return strings.TrimSpace(selectedText.String())
-	}
-
-	return scr.Render()
-}
-
-func (l *list[T]) View() string {
-	if l.height <= 0 || l.width <= 0 {
-		return ""
-	}
-
-	if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
-		return l.cachedView
-	}
-
-	t := styles.CurrentTheme()
-
-	start, end := l.viewPosition()
-	viewStart := max(0, start)
-	viewEnd := end
-
-	if viewStart > viewEnd {
-		return ""
-	}
-
-	view := l.getLines(viewStart, viewEnd)
-
-	if l.resize {
-		return view
-	}
-
-	view = t.S().Base.
-		Height(l.height).
-		Width(l.width).
-		Render(view)
-
-	if !l.hasSelection() {
-		l.cachedView = view
-		l.cachedViewOffset = l.offset
-		l.cachedViewDirty = false
-		return view
-	}
-
-	return l.selectionView(view, false)
-}
-
-func (l *list[T]) viewPosition() (int, int) {
-	start, end := 0, 0
-	renderedLines := l.renderedHeight - 1
-	if l.direction == DirectionForward {
-		start = max(0, l.offset)
-		end = min(l.offset+l.height-1, renderedLines)
-	} else {
-		start = max(0, renderedLines-l.offset-l.height+1)
-		end = max(0, renderedLines-l.offset)
-	}
-	start = min(start, end)
-	return start, end
-}
-
-func (l *list[T]) setRendered(rendered string) {
-	l.rendered = rendered
-	l.renderedHeight = lipgloss.Height(rendered)
-	l.cachedViewDirty = true // Mark view cache as dirty
-
-	if len(rendered) > 0 {
-		l.lineOffsets = make([]int, 0, l.renderedHeight)
-		l.lineOffsets = append(l.lineOffsets, 0)
-
-		offset := 0
-		for {
-			idx := strings.IndexByte(rendered[offset:], '\n')
-			if idx == -1 {
-				break
-			}
-			offset += idx + 1
-			l.lineOffsets = append(l.lineOffsets, offset)
-		}
-	} else {
-		l.lineOffsets = nil
-	}
-}
-
-func (l *list[T]) getLines(start, end int) string {
-	if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
-		return ""
-	}
-
-	if end >= len(l.lineOffsets) {
-		end = len(l.lineOffsets) - 1
-	}
-	if start > end {
-		return ""
-	}
-
-	startOffset := l.lineOffsets[start]
-	var endOffset int
-	if end+1 < len(l.lineOffsets) {
-		endOffset = l.lineOffsets[end+1] - 1
-	} else {
-		endOffset = len(l.rendered)
-	}
-
-	if startOffset >= len(l.rendered) {
-		return ""
-	}
-	endOffset = min(endOffset, len(l.rendered))
-
-	return l.rendered[startOffset:endOffset]
-}
-
-// getLine returns a single line from the rendered content using lineOffsets.
-// This avoids allocating a new string for each line like strings.Split does.
-func (l *list[T]) getLine(index int) string {
-	if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
-		return ""
-	}
-
-	startOffset := l.lineOffsets[index]
-	var endOffset int
-	if index+1 < len(l.lineOffsets) {
-		endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
-	} else {
-		endOffset = len(l.rendered)
-	}
-
-	if startOffset >= len(l.rendered) {
-		return ""
-	}
-	endOffset = min(endOffset, len(l.rendered))
-
-	return l.rendered[startOffset:endOffset]
-}
-
-// lineCount returns the number of lines in the rendered content.
-func (l *list[T]) lineCount() int {
-	return len(l.lineOffsets)
-}
-
-func (l *list[T]) recalculateItemPositions() {
-	l.recalculateItemPositionsFrom(0)
-}
-
-func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
-	var currentContentHeight int
-
-	if startIdx > 0 && startIdx <= len(l.items) {
-		prevItem := l.items[startIdx-1]
-		if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
-			currentContentHeight = rItem.end + 1 + l.gap
-		}
-	}
-
-	for i := startIdx; i < len(l.items); i++ {
-		item := l.items[i]
-		rItem, ok := l.renderedItems[item.ID()]
-		if !ok {
-			continue
-		}
-		rItem.start = currentContentHeight
-		rItem.end = currentContentHeight + rItem.height - 1
-		l.renderedItems[item.ID()] = rItem
-		currentContentHeight = rItem.end + 1 + l.gap
-	}
-}
-
-func (l *list[T]) render() tea.Cmd {
-	if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
-		return nil
-	}
-	l.setDefaultSelected()
-
-	var focusChangeCmd tea.Cmd
-	if l.focused {
-		focusChangeCmd = l.focusSelectedItem()
-	} else {
-		focusChangeCmd = l.blurSelectedItem()
-	}
-	if l.rendered != "" {
-		rendered, _ := l.renderIterator(0, false, "")
-		l.setRendered(rendered)
-		if l.direction == DirectionBackward {
-			l.recalculateItemPositions()
-		}
-		if l.focused {
-			l.scrollToSelection()
-		}
-		return focusChangeCmd
-	}
-	rendered, finishIndex := l.renderIterator(0, true, "")
-	l.setRendered(rendered)
-	if l.direction == DirectionBackward {
-		l.recalculateItemPositions()
-	}
-
-	l.offset = 0
-	rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
-	l.setRendered(rendered)
-	if l.direction == DirectionBackward {
-		l.recalculateItemPositions()
-	}
-	if l.focused {
-		l.scrollToSelection()
-	}
-
-	return focusChangeCmd
-}
-
-func (l *list[T]) setDefaultSelected() {
-	if l.selectedItemIdx < 0 {
-		if l.direction == DirectionForward {
-			l.selectFirstItem()
-		} else {
-			l.selectLastItem()
-		}
-	}
-}
-
-func (l *list[T]) scrollToSelection() {
-	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
-		l.selectedItemIdx = -1
-		l.setDefaultSelected()
-		return
-	}
-	item := l.items[l.selectedItemIdx]
-	rItem, ok := l.renderedItems[item.ID()]
-	if !ok {
-		l.selectedItemIdx = -1
-		l.setDefaultSelected()
-		return
-	}
-
-	start, end := l.viewPosition()
-	if rItem.start <= start && rItem.end >= end {
-		return
-	}
-	if l.movingByItem {
-		if rItem.start >= start && rItem.end <= end {
-			return
-		}
-		defer func() { l.movingByItem = false }()
-	} else {
-		if rItem.start >= start && rItem.start <= end {
-			return
-		}
-		if rItem.end >= start && rItem.end <= end {
-			return
-		}
-	}
-
-	if rItem.height >= l.height {
-		if l.direction == DirectionForward {
-			l.offset = rItem.start
-		} else {
-			l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
-		}
-		return
-	}
-
-	renderedLines := l.renderedHeight - 1
-
-	if rItem.start < start {
-		if l.direction == DirectionForward {
-			l.offset = rItem.start
-		} else {
-			l.offset = max(0, renderedLines-rItem.start-l.height+1)
-		}
-	} else if rItem.end > end {
-		if l.direction == DirectionForward {
-			l.offset = max(0, rItem.end-l.height+1)
-		} else {
-			l.offset = max(0, renderedLines-rItem.end)
-		}
-	}
-}
-
-func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
-	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
-		return nil
-	}
-	item := l.items[l.selectedItemIdx]
-	rItem, ok := l.renderedItems[item.ID()]
-	if !ok {
-		return nil
-	}
-	start, end := l.viewPosition()
-	// item bigger than the viewport do nothing
-	if rItem.start <= start && rItem.end >= end {
-		return nil
-	}
-	// item already in view do nothing
-	if rItem.start >= start && rItem.end <= end {
-		return nil
-	}
-
-	itemMiddle := rItem.start + rItem.height/2
-
-	if itemMiddle < start {
-		// select the first item in the viewport
-		// the item is most likely an item coming after this item
-		inx := l.selectedItemIdx
-		for {
-			inx = l.firstSelectableItemBelow(inx)
-			if inx == ItemNotFound {
-				return nil
-			}
-			if inx < 0 || inx >= len(l.items) {
-				continue
-			}
-
-			item := l.items[inx]
-			renderedItem, ok := l.renderedItems[item.ID()]
-			if !ok {
-				continue
-			}
-
-			// If the item is bigger than the viewport, select it
-			if renderedItem.start <= start && renderedItem.end >= end {
-				l.selectedItemIdx = inx
-				return l.render()
-			}
-			// item is in the view
-			if renderedItem.start >= start && renderedItem.start <= end {
-				l.selectedItemIdx = inx
-				return l.render()
-			}
-		}
-	} else if itemMiddle > end {
-		// select the first item in the viewport
-		// the item is most likely an item coming after this item
-		inx := l.selectedItemIdx
-		for {
-			inx = l.firstSelectableItemAbove(inx)
-			if inx == ItemNotFound {
-				return nil
-			}
-			if inx < 0 || inx >= len(l.items) {
-				continue
-			}
-
-			item := l.items[inx]
-			renderedItem, ok := l.renderedItems[item.ID()]
-			if !ok {
-				continue
-			}
-
-			// If the item is bigger than the viewport, select it
-			if renderedItem.start <= start && renderedItem.end >= end {
-				l.selectedItemIdx = inx
-				return l.render()
-			}
-			// item is in the view
-			if renderedItem.end >= start && renderedItem.end <= end {
-				l.selectedItemIdx = inx
-				return l.render()
-			}
-		}
-	}
-	return nil
-}
-
-func (l *list[T]) selectFirstItem() {
-	inx := l.firstSelectableItemBelow(-1)
-	if inx != ItemNotFound {
-		l.selectedItemIdx = inx
-	}
-}
-
-func (l *list[T]) selectLastItem() {
-	inx := l.firstSelectableItemAbove(len(l.items))
-	if inx != ItemNotFound {
-		l.selectedItemIdx = inx
-	}
-}
-
-func (l *list[T]) firstSelectableItemAbove(inx int) int {
-	unfocusableCount := 0
-	for i := inx - 1; i >= 0; i-- {
-		if i < 0 || i >= len(l.items) {
-			continue
-		}
-
-		item := l.items[i]
-		if _, ok := any(item).(layout.Focusable); ok {
-			return i
-		}
-		unfocusableCount++
-	}
-	if unfocusableCount == inx && l.wrap {
-		return l.firstSelectableItemAbove(len(l.items))
-	}
-	return ItemNotFound
-}
-
-func (l *list[T]) firstSelectableItemBelow(inx int) int {
-	unfocusableCount := 0
-	itemsLen := len(l.items)
-	for i := inx + 1; i < itemsLen; i++ {
-		if i < 0 || i >= len(l.items) {
-			continue
-		}
-
-		item := l.items[i]
-		if _, ok := any(item).(layout.Focusable); ok {
-			return i
-		}
-		unfocusableCount++
-	}
-	if unfocusableCount == itemsLen-inx-1 && l.wrap {
-		return l.firstSelectableItemBelow(-1)
-	}
-	return ItemNotFound
-}
-
-func (l *list[T]) focusSelectedItem() tea.Cmd {
-	if l.selectedItemIdx < 0 || !l.focused {
-		return nil
-	}
-	// Pre-allocate with expected capacity
-	cmds := make([]tea.Cmd, 0, 2)
-
-	// Blur the previously selected item if it's different
-	if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
-		prevItem := l.items[l.prevSelectedItemIdx]
-		if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
-			cmds = append(cmds, f.Blur())
-			// Mark cache as needing update, but don't delete yet
-			// This allows the render to potentially reuse it
-			delete(l.renderedItems, prevItem.ID())
-		}
-	}
-
-	// Focus the currently selected item
-	if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
-		item := l.items[l.selectedItemIdx]
-		if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
-			cmds = append(cmds, f.Focus())
-			// Mark for re-render
-			delete(l.renderedItems, item.ID())
-		}
-	}
-
-	l.prevSelectedItemIdx = l.selectedItemIdx
-	return tea.Batch(cmds...)
-}
-
-func (l *list[T]) blurSelectedItem() tea.Cmd {
-	if l.selectedItemIdx < 0 || l.focused {
-		return nil
-	}
-
-	// Blur the currently selected item
-	if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
-		item := l.items[l.selectedItemIdx]
-		if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
-			delete(l.renderedItems, item.ID())
-			return f.Blur()
-		}
-	}
-
-	return nil
-}
-
-// renderFragment holds updated rendered view fragments
-type renderFragment struct {
-	view string
-	gap  int
-}
-
-// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
-// returns the last index and the rendered content so far
-// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
-func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
-	// Pre-allocate fragments with expected capacity
-	itemsLen := len(l.items)
-	expectedFragments := itemsLen - startInx
-	if limitHeight && l.height > 0 {
-		expectedFragments = min(expectedFragments, l.height)
-	}
-	fragments := make([]renderFragment, 0, expectedFragments)
-
-	currentContentHeight := lipgloss.Height(rendered) - 1
-	finalIndex := itemsLen
-
-	// first pass: accumulate all fragments to render until the height limit is
-	// reached
-	for i := startInx; i < itemsLen; i++ {
-		if limitHeight && currentContentHeight >= l.height {
-			finalIndex = i
-			break
-		}
-		// cool way to go through the list in both directions
-		inx := i
-
-		if l.direction != DirectionForward {
-			inx = (itemsLen - 1) - i
-		}
-
-		if inx < 0 || inx >= len(l.items) {
-			continue
-		}
-
-		item := l.items[inx]
-
-		var rItem renderedItem
-		if cache, ok := l.renderedItems[item.ID()]; ok {
-			rItem = cache
-		} else {
-			rItem = l.renderItem(item)
-			rItem.start = currentContentHeight
-			rItem.end = currentContentHeight + rItem.height - 1
-			l.renderedItems[item.ID()] = rItem
-		}
-
-		gap := l.gap + 1
-		if inx == itemsLen-1 {
-			gap = 0
-		}
-
-		fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
-
-		currentContentHeight = rItem.end + 1 + l.gap
-	}
-
-	// second pass: build rendered string efficiently
-	var b strings.Builder
-
-	// Pre-size the builder to reduce allocations
-	estimatedSize := len(rendered)
-	for _, f := range fragments {
-		estimatedSize += len(f.view) + f.gap
-	}
-	b.Grow(estimatedSize)
-
-	if l.direction == DirectionForward {
-		b.WriteString(rendered)
-		for i := range fragments {
-			f := &fragments[i]
-			b.WriteString(f.view)
-			// Optimized gap writing using pre-allocated buffer
-			if f.gap > 0 {
-				if f.gap <= maxGapSize {
-					b.WriteString(newlineBuffer[:f.gap])
-				} else {
-					b.WriteString(strings.Repeat("\n", f.gap))
-				}
-			}
-		}
-
-		return b.String(), finalIndex
-	}
-
-	// iterate backwards as fragments are in reversed order
-	for i := len(fragments) - 1; i >= 0; i-- {
-		f := &fragments[i]
-		b.WriteString(f.view)
-		// Optimized gap writing using pre-allocated buffer
-		if f.gap > 0 {
-			if f.gap <= maxGapSize {
-				b.WriteString(newlineBuffer[:f.gap])
-			} else {
-				b.WriteString(strings.Repeat("\n", f.gap))
-			}
-		}
-	}
-	b.WriteString(rendered)
-
-	return b.String(), finalIndex
-}
-
-func (l *list[T]) renderItem(item Item) renderedItem {
-	view := item.View()
-	return renderedItem{
-		view:   view,
-		height: lipgloss.Height(view),
-	}
-}
-
-// AppendItem implements List.
-func (l *list[T]) AppendItem(item T) tea.Cmd {
-	// Pre-allocate with expected capacity
-	cmds := make([]tea.Cmd, 0, 4)
-	cmd := item.Init()
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-
-	newIndex := len(l.items)
-	l.items = append(l.items, item)
-	l.indexMap[item.ID()] = newIndex
-
-	if l.width > 0 && l.height > 0 {
-		cmd = item.SetSize(l.width, l.height)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	}
-	cmd = l.render()
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-	if l.direction == DirectionBackward {
-		if l.offset == 0 {
-			cmd = l.GoToBottom()
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		} else {
-			newItem, ok := l.renderedItems[item.ID()]
-			if ok {
-				newLines := newItem.height
-				if len(l.items) > 1 {
-					newLines += l.gap
-				}
-				l.offset = min(l.renderedHeight-1, l.offset+newLines)
-			}
-		}
-	}
-	return tea.Sequence(cmds...)
-}
-
-// Blur implements List.
-func (l *list[T]) Blur() tea.Cmd {
-	l.focused = false
-	return l.render()
-}
-
-// DeleteItem implements List.
-func (l *list[T]) DeleteItem(id string) tea.Cmd {
-	inx, ok := l.indexMap[id]
-	if !ok {
-		return nil
-	}
-	l.items = append(l.items[:inx], l.items[inx+1:]...)
-	delete(l.renderedItems, id)
-	delete(l.indexMap, id)
-
-	// Only update indices for items after the deleted one
-	itemsLen := len(l.items)
-	for i := inx; i < itemsLen; i++ {
-		if i >= 0 && i < len(l.items) {
-			item := l.items[i]
-			l.indexMap[item.ID()] = i
-		}
-	}
-
-	// Adjust selectedItemIdx if the deleted item was selected or before it
-	if l.selectedItemIdx == inx {
-		// Deleted item was selected, select the previous item if possible
-		if inx > 0 {
-			l.selectedItemIdx = inx - 1
-		} else {
-			l.selectedItemIdx = -1
-		}
-	} else if l.selectedItemIdx > inx {
-		// Selected item is after the deleted one, shift index down
-		l.selectedItemIdx--
-	}
-	cmd := l.render()
-	if l.rendered != "" {
-		if l.renderedHeight <= l.height {
-			l.offset = 0
-		} else {
-			maxOffset := l.renderedHeight - l.height
-			if l.offset > maxOffset {
-				l.offset = maxOffset
-			}
-		}
-	}
-	return cmd
-}
-
-// Focus implements List.
-func (l *list[T]) Focus() tea.Cmd {
-	l.focused = true
-	return l.render()
-}
-
-// GetSize implements List.
-func (l *list[T]) GetSize() (int, int) {
-	return l.width, l.height
-}
-
-// GoToBottom implements List.
-func (l *list[T]) GoToBottom() tea.Cmd {
-	l.offset = 0
-	l.selectedItemIdx = -1
-	l.direction = DirectionBackward
-	return l.render()
-}
-
-// GoToTop implements List.
-func (l *list[T]) GoToTop() tea.Cmd {
-	l.offset = 0
-	l.selectedItemIdx = -1
-	l.direction = DirectionForward
-	return l.render()
-}
-
-// IsFocused implements List.
-func (l *list[T]) IsFocused() bool {
-	return l.focused
-}
-
-// Items implements List.
-func (l *list[T]) Items() []T {
-	itemsLen := len(l.items)
-	result := make([]T, 0, itemsLen)
-	for i := range itemsLen {
-		if i >= 0 && i < len(l.items) {
-			item := l.items[i]
-			result = append(result, item)
-		}
-	}
-	return result
-}
-
-func (l *list[T]) incrementOffset(n int) {
-	// no need for offset
-	if l.renderedHeight <= l.height {
-		return
-	}
-	maxOffset := l.renderedHeight - l.height
-	n = min(n, maxOffset-l.offset)
-	if n <= 0 {
-		return
-	}
-	l.offset += n
-	l.cachedViewDirty = true
-}
-
-func (l *list[T]) decrementOffset(n int) {
-	n = min(n, l.offset)
-	if n <= 0 {
-		return
-	}
-	l.offset -= n
-	if l.offset < 0 {
-		l.offset = 0
-	}
-	l.cachedViewDirty = true
-}
-
-// MoveDown implements List.
-func (l *list[T]) MoveDown(n int) tea.Cmd {
-	oldOffset := l.offset
-	if l.direction == DirectionForward {
-		l.incrementOffset(n)
-	} else {
-		l.decrementOffset(n)
-	}
-
-	if oldOffset == l.offset {
-		// no change in offset, so no need to change selection
-		return nil
-	}
-	// if we are not actively selecting move the whole selection down
-	if l.hasSelection() && !l.selectionActive {
-		if l.selectionStartLine < l.selectionEndLine {
-			l.selectionStartLine -= n
-			l.selectionEndLine -= n
-		} else {
-			l.selectionStartLine -= n
-			l.selectionEndLine -= n
-		}
-	}
-	if l.selectionActive {
-		if l.selectionStartLine < l.selectionEndLine {
-			l.selectionStartLine -= n
-		} else {
-			l.selectionEndLine -= n
-		}
-	}
-	return l.changeSelectionWhenScrolling()
-}
-
-// MoveUp implements List.
-func (l *list[T]) MoveUp(n int) tea.Cmd {
-	oldOffset := l.offset
-	if l.direction == DirectionForward {
-		l.decrementOffset(n)
-	} else {
-		l.incrementOffset(n)
-	}
-
-	if oldOffset == l.offset {
-		// no change in offset, so no need to change selection
-		return nil
-	}
-
-	if l.hasSelection() && !l.selectionActive {
-		if l.selectionStartLine > l.selectionEndLine {
-			l.selectionStartLine += n
-			l.selectionEndLine += n
-		} else {
-			l.selectionStartLine += n
-			l.selectionEndLine += n
-		}
-	}
-	if l.selectionActive {
-		if l.selectionStartLine > l.selectionEndLine {
-			l.selectionStartLine += n
-		} else {
-			l.selectionEndLine += n
-		}
-	}
-	return l.changeSelectionWhenScrolling()
-}
-
-// PrependItem implements List.
-func (l *list[T]) PrependItem(item T) tea.Cmd {
-	// Pre-allocate with expected capacity
-	cmds := make([]tea.Cmd, 0, 4)
-	cmds = append(cmds, item.Init())
-
-	l.items = append([]T{item}, l.items...)
-
-	// Shift selectedItemIdx since all items moved down by 1
-	if l.selectedItemIdx >= 0 {
-		l.selectedItemIdx++
-	}
-
-	// Update index map incrementally: shift all existing indices up by 1
-	// This is more efficient than rebuilding from scratch
-	newIndexMap := make(map[string]int, len(l.indexMap)+1)
-	for id, idx := range l.indexMap {
-		newIndexMap[id] = idx + 1 // All existing items shift down by 1
-	}
-	newIndexMap[item.ID()] = 0 // New item is at index 0
-	l.indexMap = newIndexMap
-
-	if l.width > 0 && l.height > 0 {
-		cmds = append(cmds, item.SetSize(l.width, l.height))
-	}
-	cmds = append(cmds, l.render())
-	if l.direction == DirectionForward {
-		if l.offset == 0 {
-			cmd := l.GoToTop()
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		} else {
-			newItem, ok := l.renderedItems[item.ID()]
-			if ok {
-				newLines := newItem.height
-				if len(l.items) > 1 {
-					newLines += l.gap
-				}
-				l.offset = min(l.renderedHeight-1, l.offset+newLines)
-			}
-		}
-	}
-	return tea.Batch(cmds...)
-}
-
-// SelectItemAbove implements List.
-func (l *list[T]) SelectItemAbove() tea.Cmd {
-	if l.selectedItemIdx < 0 {
-		return nil
-	}
-
-	newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
-	if newIndex == ItemNotFound {
-		// no item above
-		return nil
-	}
-	// Pre-allocate with expected capacity
-	cmds := make([]tea.Cmd, 0, 2)
-	if newIndex > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 {
-		// this means there is a section above and not showing on the top, move to the top
-		newIndex = l.selectedItemIdx
-		cmd := l.GoToTop()
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	}
-	if newIndex == 1 {
-		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
-		if peakAboveIndex == ItemNotFound {
-			// this means there is a section above move to the top
-			cmd := l.GoToTop()
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-	}
-	if newIndex < 0 || newIndex >= len(l.items) {
-		return nil
-	}
-	l.prevSelectedItemIdx = l.selectedItemIdx
-	l.selectedItemIdx = newIndex
-	l.movingByItem = true
-	renderCmd := l.render()
-	if renderCmd != nil {
-		cmds = append(cmds, renderCmd)
-	}
-	return tea.Sequence(cmds...)
-}
-
-// SelectItemBelow implements List.
-func (l *list[T]) SelectItemBelow() tea.Cmd {
-	if l.selectedItemIdx < 0 {
-		return nil
-	}
-
-	newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
-	if newIndex == ItemNotFound {
-		// no item below
-		return nil
-	}
-	if newIndex < 0 || newIndex >= len(l.items) {
-		return nil
-	}
-	if newIndex < l.selectedItemIdx {
-		// reset offset when wrap to the top to show the top section if it exists
-		l.offset = 0
-	}
-	l.prevSelectedItemIdx = l.selectedItemIdx
-	l.selectedItemIdx = newIndex
-	l.movingByItem = true
-	return l.render()
-}
-
-// SelectedItem implements List.
-func (l *list[T]) SelectedItem() *T {
-	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
-		return nil
-	}
-	item := l.items[l.selectedItemIdx]
-	return &item
-}
-
-// SetItems implements List.
-func (l *list[T]) SetItems(items []T) tea.Cmd {
-	l.items = items
-	var cmds []tea.Cmd
-	for inx, item := range items {
-		if i, ok := any(item).(Indexable); ok {
-			i.SetIndex(inx)
-		}
-		cmds = append(cmds, item.Init())
-	}
-	cmds = append(cmds, l.reset(""))
-	return tea.Batch(cmds...)
-}
-
-// SetSelected implements List.
-func (l *list[T]) SetSelected(id string) tea.Cmd {
-	l.prevSelectedItemIdx = l.selectedItemIdx
-	if idx, ok := l.indexMap[id]; ok {
-		l.selectedItemIdx = idx
-	} else {
-		l.selectedItemIdx = -1
-	}
-	return l.render()
-}
-
-func (l *list[T]) reset(selectedItemID string) tea.Cmd {
-	var cmds []tea.Cmd
-	l.rendered = ""
-	l.renderedHeight = 0
-	l.offset = 0
-	l.indexMap = make(map[string]int)
-	l.renderedItems = make(map[string]renderedItem)
-	itemsLen := len(l.items)
-	for i := range itemsLen {
-		if i < 0 || i >= len(l.items) {
-			continue
-		}
-
-		item := l.items[i]
-		l.indexMap[item.ID()] = i
-		if l.width > 0 && l.height > 0 {
-			cmds = append(cmds, item.SetSize(l.width, l.height))
-		}
-	}
-	// Convert selectedItemID to index after rebuilding indexMap
-	if selectedItemID != "" {
-		if idx, ok := l.indexMap[selectedItemID]; ok {
-			l.selectedItemIdx = idx
-		} else {
-			l.selectedItemIdx = -1
-		}
-	} else {
-		l.selectedItemIdx = -1
-	}
-	cmds = append(cmds, l.render())
-	return tea.Batch(cmds...)
-}
-
-// SetSize implements List.
-func (l *list[T]) SetSize(width int, height int) tea.Cmd {
-	oldWidth := l.width
-	oldHeight := l.height
-	l.width = width
-	l.height = height
-	// Invalidate cache if height changed
-	if oldHeight != height {
-		l.cachedViewDirty = true
-	}
-	if oldWidth != width {
-		// Get current selected item ID before reset
-		selectedID := ""
-		if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
-			item := l.items[l.selectedItemIdx]
-			selectedID = item.ID()
-		}
-		cmd := l.reset(selectedID)
-		return cmd
-	}
-	return nil
-}
-
-// UpdateItem implements List.
-func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
-	// Pre-allocate with expected capacity
-	cmds := make([]tea.Cmd, 0, 1)
-	if inx, ok := l.indexMap[id]; ok {
-		l.items[inx] = item
-		oldItem, hasOldItem := l.renderedItems[id]
-		oldPosition := l.offset
-		if l.direction == DirectionBackward {
-			oldPosition = (l.renderedHeight - 1) - l.offset
-		}
-
-		delete(l.renderedItems, id)
-		cmd := l.render()
-
-		// need to check for nil because of sequence not handling nil
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-		if hasOldItem && l.direction == DirectionBackward {
-			// if we are the last item and there is no offset
-			// make sure to go to the bottom
-			if oldPosition < oldItem.end {
-				newItem, ok := l.renderedItems[item.ID()]
-				if ok {
-					newLines := newItem.height - oldItem.height
-					l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
-				}
-			}
-		} else if hasOldItem && l.offset > oldItem.start {
-			newItem, ok := l.renderedItems[item.ID()]
-			if ok {
-				newLines := newItem.height - oldItem.height
-				l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
-			}
-		}
-	}
-	return tea.Sequence(cmds...)
-}
-
-func (l *list[T]) hasSelection() bool {
-	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
-}
-
-// StartSelection implements List.
-func (l *list[T]) StartSelection(col, line int) {
-	l.selectionStartCol = col
-	l.selectionStartLine = line
-	l.selectionEndCol = col
-	l.selectionEndLine = line
-	l.selectionActive = true
-}
-
-// EndSelection implements List.
-func (l *list[T]) EndSelection(col, line int) {
-	if !l.selectionActive {
-		return
-	}
-	l.selectionEndCol = col
-	l.selectionEndLine = line
-}
-
-func (l *list[T]) SelectionStop() {
-	l.selectionActive = false
-}
-
-func (l *list[T]) SelectionClear() {
-	l.selectionStartCol = -1
-	l.selectionStartLine = -1
-	l.selectionEndCol = -1
-	l.selectionEndLine = -1
-	l.selectionActive = false
-}
-
-func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
-	numLines := l.lineCount()
-
-	if l.direction == DirectionBackward && numLines > l.height {
-		line = ((numLines - 1) - l.height) + line + 1
-	}
-
-	if l.offset > 0 {
-		if l.direction == DirectionBackward {
-			line -= l.offset
-		} else {
-			line += l.offset
-		}
-	}
-
-	if line < 0 || line >= numLines {
-		return 0, 0
-	}
-
-	currentLine := ansi.Strip(l.getLine(line))
-	gr := uniseg.NewGraphemes(currentLine)
-	startCol = -1
-	upTo := col
-	for gr.Next() {
-		if gr.IsWordBoundary() && upTo > 0 {
-			startCol = col - upTo + 1
-		} else if gr.IsWordBoundary() && upTo < 0 {
-			endCol = col - upTo + 1
-			break
-		}
-		if upTo == 0 && gr.Str() == " " {
-			return 0, 0
-		}
-		upTo -= 1
-	}
-	if startCol == -1 {
-		return 0, 0
-	}
-	return startCol, endCol
-}
-
-func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
-	// Helper function to get a line with ANSI stripped and icons replaced
-	getCleanLine := func(index int) string {
-		rawLine := l.getLine(index)
-		cleanLine := ansi.Strip(rawLine)
-		for _, icon := range styles.SelectionIgnoreIcons {
-			cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
-		}
-		return cleanLine
-	}
-
-	numLines := l.lineCount()
-	if l.direction == DirectionBackward && numLines > l.height {
-		line = (numLines - 1) - l.height + line + 1
-	}
-
-	if l.offset > 0 {
-		if l.direction == DirectionBackward {
-			line -= l.offset
-		} else {
-			line += l.offset
-		}
-	}
-
-	// Ensure line is within bounds
-	if line < 0 || line >= numLines {
-		return 0, 0, false
-	}
-
-	if strings.TrimSpace(getCleanLine(line)) == "" {
-		return 0, 0, false
-	}
-
-	// Find start of paragraph (search backwards for empty line or start of text)
-	startLine = line
-	for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
-		startLine--
-	}
-
-	// Find end of paragraph (search forwards for empty line or end of text)
-	endLine = line
-	for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
-		endLine++
-	}
-
-	// revert the line numbers if we are in backward direction
-	if l.direction == DirectionBackward && numLines > l.height {
-		startLine = startLine - (numLines - 1) + l.height - 1
-		endLine = endLine - (numLines - 1) + l.height - 1
-	}
-	if l.offset > 0 {
-		if l.direction == DirectionBackward {
-			startLine += l.offset
-			endLine += l.offset
-		} else {
-			startLine -= l.offset
-			endLine -= l.offset
-		}
-	}
-	return startLine, endLine, true
-}
-
-// SelectWord selects the word at the given position.
-func (l *list[T]) SelectWord(col, line int) {
-	startCol, endCol := l.findWordBoundaries(col, line)
-	l.selectionStartCol = startCol
-	l.selectionStartLine = line
-	l.selectionEndCol = endCol
-	l.selectionEndLine = line
-	l.selectionActive = false // Not actively selecting, just selected
-}
-
-// SelectParagraph selects the paragraph at the given position.
-func (l *list[T]) SelectParagraph(col, line int) {
-	startLine, endLine, found := l.findParagraphBoundaries(line)
-	if !found {
-		return
-	}
-	l.selectionStartCol = 0
-	l.selectionStartLine = startLine
-	l.selectionEndCol = l.width - 1
-	l.selectionEndLine = endLine
-	l.selectionActive = false // Not actively selecting, just selected
-}
-
-// HasSelection returns whether there is an active selection.
-func (l *list[T]) HasSelection() bool {
-	return l.hasSelection()
-}
-
-func (l *list[T]) selectionArea(absolute bool) uv.Rectangle {
-	var startY int
-	if absolute {
-		startY, _ = l.viewPosition()
-	}
-	selArea := uv.Rectangle{
-		Min: uv.Pos(l.selectionStartCol, l.selectionStartLine+startY),
-		Max: uv.Pos(l.selectionEndCol, l.selectionEndLine+startY),
-	}
-	selArea = selArea.Canon()
-	selArea.Max.Y++ // make max Y exclusive
-	return selArea
-}
-
-// GetSelectedText returns the currently selected text.
-func (l *list[T]) GetSelectedText(paddingLeft int) string {
-	if !l.hasSelection() {
-		return ""
-	}
-
-	selArea := l.selectionArea(true)
-	if selArea.Empty() {
-		return ""
-	}
-
-	selectionHeight := selArea.Dy()
-
-	tempBuf := uv.NewScreenBuffer(l.width, selectionHeight)
-	tempBufArea := tempBuf.Bounds()
-	renderedLines := l.getLines(selArea.Min.Y, selArea.Max.Y)
-	styled := uv.NewStyledString(renderedLines)
-	styled.Draw(tempBuf, tempBufArea)
-
-	// XXX: Left padding assumes the list component is rendered with absolute
-	// positioning. The chat component has a left margin of 1 and items in the
-	// list have a border of 1 plus a padding of 1. The paddingLeft parameter
-	// assumes this total left padding of 3 and we should fix that.
-	leftBorder := paddingLeft - 1
-
-	var b strings.Builder
-	for y := tempBufArea.Min.Y; y < tempBufArea.Max.Y; y++ {
-		var pending strings.Builder
-		for x := tempBufArea.Min.X + leftBorder; x < tempBufArea.Max.X; {
-			cell := tempBuf.CellAt(x, y)
-			if cell == nil || cell.IsZero() {
-				x++
-				continue
-			}
-			if y == 0 && x < selArea.Min.X {
-				x++
-				continue
-			}
-			if y == selectionHeight-1 && x > selArea.Max.X-1 {
-				break
-			}
-			if cell.Width == 1 && cell.Content == " " {
-				pending.WriteString(cell.Content)
-				x++
-				continue
-			}
-			b.WriteString(pending.String())
-			pending.Reset()
-			b.WriteString(cell.Content)
-			x += cell.Width
-		}
-		if y < tempBufArea.Max.Y-1 {
-			b.WriteByte('\n')
-		}
-	}
-
-	return b.String()
-}

internal/tui/exp/list/list_test.go 🔗

@@ -1,653 +0,0 @@
-package list
-
-import (
-	"fmt"
-	"strings"
-	"testing"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/x/exp/golden"
-	"github.com/google/uuid"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestList(t *testing.T) {
-	t.Parallel()
-	t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 5 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
-		execCmd(l, l.Init())
-
-		// should select the last item
-		assert.Equal(t, 0, l.selectedItemIdx)
-		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 5, len(l.indexMap))
-		require.Equal(t, 5, len(l.items))
-		require.Equal(t, 5, len(l.renderedItems))
-		assert.Equal(t, 5, lipgloss.Height(l.rendered))
-		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
-		start, end := l.viewPosition()
-		assert.Equal(t, 0, start)
-		assert.Equal(t, 4, end)
-		for i := range 5 {
-			item, ok := l.renderedItems[items[i].ID()]
-			require.True(t, ok)
-			assert.Equal(t, i, item.start)
-			assert.Equal(t, i, item.end)
-		}
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 5 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
-		execCmd(l, l.Init())
-
-		// should select the last item
-		assert.Equal(t, 4, l.selectedItemIdx)
-		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 5, len(l.indexMap))
-		require.Equal(t, 5, len(l.items))
-		require.Equal(t, 5, len(l.renderedItems))
-		assert.Equal(t, 5, lipgloss.Height(l.rendered))
-		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
-		start, end := l.viewPosition()
-		assert.Equal(t, 0, start)
-		assert.Equal(t, 4, end)
-		for i := range 5 {
-			item, ok := l.renderedItems[items[i].ID()]
-			require.True(t, ok)
-			assert.Equal(t, i, item.start)
-			assert.Equal(t, i, item.end)
-		}
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		// should select the last item
-		assert.Equal(t, 0, l.selectedItemIdx)
-		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 30, len(l.indexMap))
-		require.Equal(t, 30, len(l.items))
-		require.Equal(t, 30, len(l.renderedItems))
-		assert.Equal(t, 30, lipgloss.Height(l.rendered))
-		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
-		start, end := l.viewPosition()
-		assert.Equal(t, 0, start)
-		assert.Equal(t, 9, end)
-		for i := range 30 {
-			item, ok := l.renderedItems[items[i].ID()]
-			require.True(t, ok)
-			assert.Equal(t, i, item.start)
-			assert.Equal(t, i, item.end)
-		}
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		// should select the last item
-		assert.Equal(t, 29, l.selectedItemIdx)
-		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 30, len(l.indexMap))
-		require.Equal(t, 30, len(l.items))
-		require.Equal(t, 30, len(l.renderedItems))
-		assert.Equal(t, 30, lipgloss.Height(l.rendered))
-		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
-		start, end := l.viewPosition()
-		assert.Equal(t, 20, start)
-		assert.Equal(t, 29, end)
-		for i := range 30 {
-			item, ok := l.renderedItems[items[i].ID()]
-			require.True(t, ok)
-			assert.Equal(t, i, item.start)
-			assert.Equal(t, i, item.end)
-		}
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		// should select the last item
-		assert.Equal(t, 0, l.selectedItemIdx)
-		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 30, len(l.indexMap))
-		require.Equal(t, 30, len(l.items))
-		require.Equal(t, 30, len(l.renderedItems))
-		expectedLines := 0
-		for i := range 30 {
-			expectedLines += (i + 1) * 1
-		}
-		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
-		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
-		start, end := l.viewPosition()
-		assert.Equal(t, 0, start)
-		assert.Equal(t, 9, end)
-		currentPosition := 0
-		for i := range 30 {
-			rItem, ok := l.renderedItems[items[i].ID()]
-			require.True(t, ok)
-			assert.Equal(t, currentPosition, rItem.start)
-			assert.Equal(t, currentPosition+i, rItem.end)
-			currentPosition += i + 1
-		}
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		// should select the last item
-		assert.Equal(t, 29, l.selectedItemIdx)
-		assert.Equal(t, 0, l.offset)
-		require.Equal(t, 30, len(l.indexMap))
-		require.Equal(t, 30, len(l.items))
-		require.Equal(t, 30, len(l.renderedItems))
-		expectedLines := 0
-		for i := range 30 {
-			expectedLines += (i + 1) * 1
-		}
-		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
-		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
-		start, end := l.viewPosition()
-		assert.Equal(t, expectedLines-10, start)
-		assert.Equal(t, expectedLines-1, end)
-		currentPosition := 0
-		for i := range 30 {
-			rItem, ok := l.renderedItems[items[i].ID()]
-			require.True(t, ok)
-			assert.Equal(t, currentPosition, rItem.start)
-			assert.Equal(t, currentPosition+i, rItem.end)
-			currentPosition += i + 1
-		}
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should go to selected item at the beginning", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
-		execCmd(l, l.Init())
-
-		// should select the last item
-		assert.Equal(t, 10, l.selectedItemIdx)
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
-		execCmd(l, l.Init())
-
-		// should select the last item
-		assert.Equal(t, 10, l.selectedItemIdx)
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-}
-
-func TestListMovement(t *testing.T) {
-	t.Parallel()
-	t.Run("should move viewport up", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveUp(25))
-
-		assert.Equal(t, 25, l.offset)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should move viewport up and down", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveUp(25))
-		execCmd(l, l.MoveDown(25))
-
-		assert.Equal(t, 0, l.offset)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should move viewport down", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveDown(25))
-
-		assert.Equal(t, 25, l.offset)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should move viewport down and up", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveDown(25))
-		execCmd(l, l.MoveUp(25))
-
-		assert.Equal(t, 0, l.offset)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-		execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
-
-		assert.Equal(t, 0, l.offset)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveUp(2))
-		viewBefore := l.View()
-		execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 5, l.offset)
-		assert.Equal(t, 33, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveUp(2))
-		viewBefore := l.View()
-		item := items[29]
-		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 4, l.offset)
-		assert.Equal(t, 32, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveUp(2))
-		viewBefore := l.View()
-		item := items[30]
-		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 0, l.offset)
-		assert.Equal(t, 31, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveUp(2))
-		viewBefore := l.View()
-		item := items[1]
-		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 2, l.offset)
-		assert.Equal(t, 32, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveUp(2))
-		viewBefore := l.View()
-		execCmd(l, l.PrependItem(NewSelectableItem("New")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 2, l.offset)
-		assert.Equal(t, 31, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
-			content = strings.TrimSuffix(content, "\n")
-			item := NewSelectableItem(content)
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-		execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
-
-		assert.Equal(t, 0, l.offset)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveDown(2))
-		viewBefore := l.View()
-		execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 5, l.offset)
-		assert.Equal(t, 33, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveDown(2))
-		viewBefore := l.View()
-		item := items[0]
-		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 4, l.offset)
-		assert.Equal(t, 32, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveDown(3))
-		viewBefore := l.View()
-		item := items[0]
-		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 1, l.offset)
-		assert.Equal(t, 31, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-
-	t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveDown(2))
-		viewBefore := l.View()
-		item := items[29]
-		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 2, l.offset)
-		assert.Equal(t, 32, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 30 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
-		execCmd(l, l.Init())
-
-		execCmd(l, l.MoveDown(2))
-		viewBefore := l.View()
-		execCmd(l, l.AppendItem(NewSelectableItem("New")))
-		viewAfter := l.View()
-		assert.Equal(t, viewBefore, viewAfter)
-		assert.Equal(t, 2, l.offset)
-		assert.Equal(t, 31, lipgloss.Height(l.rendered))
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-}
-
-type SelectableItem interface {
-	Item
-	layout.Focusable
-}
-
-type simpleItem struct {
-	width   int
-	content string
-	id      string
-}
-type selectableItem struct {
-	*simpleItem
-	focused bool
-}
-
-func NewSimpleItem(content string) *simpleItem {
-	return &simpleItem{
-		id:      uuid.NewString(),
-		width:   0,
-		content: content,
-	}
-}
-
-func NewSelectableItem(content string) SelectableItem {
-	return &selectableItem{
-		simpleItem: NewSimpleItem(content),
-		focused:    false,
-	}
-}
-
-func (s *simpleItem) ID() string {
-	return s.id
-}
-
-func (s *simpleItem) Init() tea.Cmd {
-	return nil
-}
-
-func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	return s, nil
-}
-
-func (s *simpleItem) View() string {
-	return lipgloss.NewStyle().Width(s.width).Render(s.content)
-}
-
-func (l *simpleItem) GetSize() (int, int) {
-	return l.width, 0
-}
-
-// SetSize implements Item.
-func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
-	s.width = width
-	return nil
-}
-
-func (s *selectableItem) View() string {
-	if s.focused {
-		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
-	}
-	return lipgloss.NewStyle().Width(s.width).Render(s.content)
-}
-
-// Blur implements SimpleItem.
-func (s *selectableItem) Blur() tea.Cmd {
-	s.focused = false
-	return nil
-}
-
-// Focus implements SimpleItem.
-func (s *selectableItem) Focus() tea.Cmd {
-	s.focused = true
-	return nil
-}
-
-// IsFocused implements SimpleItem.
-func (s *selectableItem) IsFocused() bool {
-	return s.focused
-}
-
-func execCmd(m util.Model, cmd tea.Cmd) {
-	for cmd != nil {
-		msg := cmd()
-		m, cmd = m.Update(msg)
-	}
-}

internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden 🔗

@@ -1,10 +0,0 @@
-│Item 29  
-│Item 29  
-│Item 29  
-│Item 29  
-│Item 29  
-│Item 29  
-│Item 29  
-│Item 29  
-│Item 29  
-│Item 29  

internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden 🔗

@@ -1,10 +0,0 @@
-Item 6    
-Item 6    
-Item 6    
-│Item 7   
-│Item 7   
-│Item 7   
-│Item 7   
-│Item 7   
-│Item 7   
-│Item 7   

internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden 🔗

@@ -1,10 +0,0 @@
-│Item 28  
-│Item 28  
-│Item 28  
-│Item 28  
-│Item 28  
-Item 29   
-Item 29   
-Item 29   
-Item 29   
-Item 29   

internal/tui/highlight/highlight.go 🔗

@@ -1,54 +0,0 @@
-package highlight
-
-import (
-	"bytes"
-	"image/color"
-
-	"github.com/alecthomas/chroma/v2"
-	"github.com/alecthomas/chroma/v2/formatters"
-	"github.com/alecthomas/chroma/v2/lexers"
-	chromaStyles "github.com/alecthomas/chroma/v2/styles"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
-	// Determine the language lexer to use
-	l := lexers.Match(fileName)
-	if l == nil {
-		l = lexers.Analyse(source)
-	}
-	if l == nil {
-		l = lexers.Fallback
-	}
-	l = chroma.Coalesce(l)
-
-	// Get the formatter
-	f := formatters.Get("terminal16m")
-	if f == nil {
-		f = formatters.Fallback
-	}
-
-	style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
-
-	// Modify the style to use the provided background
-	s, err := style.Builder().Transform(
-		func(t chroma.StyleEntry) chroma.StyleEntry {
-			r, g, b, _ := bg.RGBA()
-			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
-			return t
-		},
-	).Build()
-	if err != nil {
-		s = chromaStyles.Fallback
-	}
-
-	// Tokenize and format
-	it, err := l.Tokenise(nil, source)
-	if err != nil {
-		return "", err
-	}
-
-	var buf bytes.Buffer
-	err = f.Format(&buf, s, it)
-	return buf.String(), err
-}

internal/tui/keys.go 🔗

@@ -1,45 +0,0 @@
-package tui
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	Quit     key.Binding
-	Help     key.Binding
-	Commands key.Binding
-	Suspend  key.Binding
-	Models   key.Binding
-	Sessions key.Binding
-
-	pageBindings []key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Quit: key.NewBinding(
-			key.WithKeys("ctrl+c"),
-			key.WithHelp("ctrl+c", "quit"),
-		),
-		Help: key.NewBinding(
-			key.WithKeys("ctrl+g"),
-			key.WithHelp("ctrl+g", "more"),
-		),
-		Commands: key.NewBinding(
-			key.WithKeys("ctrl+p"),
-			key.WithHelp("ctrl+p", "commands"),
-		),
-		Suspend: key.NewBinding(
-			key.WithKeys("ctrl+z"),
-			key.WithHelp("ctrl+z", "suspend"),
-		),
-		Models: key.NewBinding(
-			key.WithKeys("ctrl+l", "ctrl+m"),
-			key.WithHelp("ctrl+l", "models"),
-		),
-		Sessions: key.NewBinding(
-			key.WithKeys("ctrl+s"),
-			key.WithHelp("ctrl+s", "sessions"),
-		),
-	}
-}

internal/tui/page/chat/chat.go 🔗

@@ -1,1407 +0,0 @@
-package chat
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"time"
-
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/key"
-	"charm.land/bubbles/v2/spinner"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/anim"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
-	"github.com/charmbracelet/crush/internal/tui/components/completions"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
-	"github.com/charmbracelet/crush/internal/tui/page"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/crush/internal/version"
-)
-
-var ChatPageID page.PageID = "chat"
-
-type (
-	ChatFocusedMsg struct {
-		Focused bool
-	}
-	CancelTimerExpiredMsg struct{}
-)
-
-type PanelType string
-
-const (
-	PanelTypeChat   PanelType = "chat"
-	PanelTypeEditor PanelType = "editor"
-	PanelTypeSplash PanelType = "splash"
-)
-
-// PillSection represents which pill section is focused when in pills panel.
-type PillSection int
-
-const (
-	PillSectionTodos PillSection = iota
-	PillSectionQueue
-)
-
-const (
-	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
-	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
-	EditorHeight                = 5   // Height of the editor input area including padding
-	SideBarWidth                = 31  // Width of the sidebar
-	SideBarDetailsPadding       = 1   // Padding for the sidebar details section
-	HeaderHeight                = 1   // Height of the header
-
-	// Layout constants for borders and padding
-	BorderWidth        = 1 // Width of component borders
-	LeftRightBorders   = 2 // Left + right border width (1 + 1)
-	TopBottomBorders   = 2 // Top + bottom border width (1 + 1)
-	DetailsPositioning = 2 // Positioning adjustment for details panel
-
-	// Timing constants
-	CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
-)
-
-type ChatPage interface {
-	util.Model
-	layout.Help
-	IsChatFocused() bool
-}
-
-// cancelTimerCmd creates a command that expires the cancel timer
-func cancelTimerCmd() tea.Cmd {
-	return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
-		return CancelTimerExpiredMsg{}
-	})
-}
-
-type chatPage struct {
-	width, height               int
-	detailsWidth, detailsHeight int
-	app                         *app.App
-	keyboardEnhancements        tea.KeyboardEnhancementsMsg
-
-	// Layout state
-	compact      bool
-	forceCompact bool
-	focusedPane  PanelType
-
-	// Session
-	session session.Session
-	keyMap  KeyMap
-
-	// Components
-	header  header.Header
-	sidebar sidebar.Sidebar
-	chat    chat.MessageListCmp
-	editor  editor.Editor
-	splash  splash.Splash
-
-	// Simple state flags
-	showingDetails   bool
-	isCanceling      bool
-	splashFullScreen bool
-	isOnboarding     bool
-	isProjectInit    bool
-	promptQueue      int
-
-	// Pills state
-	pillsExpanded      bool
-	focusedPillSection PillSection
-
-	// Todo spinner
-	todoSpinner spinner.Model
-}
-
-func New(app *app.App) ChatPage {
-	t := styles.CurrentTheme()
-	return &chatPage{
-		app:         app,
-		keyMap:      DefaultKeyMap(),
-		header:      header.New(app.LSPClients),
-		sidebar:     sidebar.New(app.History, app.LSPClients, false),
-		chat:        chat.New(app),
-		editor:      editor.New(app),
-		splash:      splash.New(),
-		focusedPane: PanelTypeSplash,
-		todoSpinner: spinner.New(
-			spinner.WithSpinner(spinner.MiniDot),
-			spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
-		),
-	}
-}
-
-func (p *chatPage) Init() tea.Cmd {
-	cfg := config.Get()
-	compact := cfg.Options.TUI.CompactMode
-	p.compact = compact
-	p.forceCompact = compact
-	p.sidebar.SetCompactMode(p.compact)
-
-	// Set splash state based on config
-	if !config.HasInitialDataConfig() {
-		// First-time setup: show model selection
-		p.splash.SetOnboarding(true)
-		p.isOnboarding = true
-		p.splashFullScreen = true
-	} else if b, _ := config.ProjectNeedsInitialization(); b {
-		// Project needs context initialization
-		p.splash.SetProjectInit(true)
-		p.isProjectInit = true
-		p.splashFullScreen = true
-	} else {
-		// Ready to chat: focus editor, splash in background
-		p.focusedPane = PanelTypeEditor
-		p.splashFullScreen = false
-	}
-
-	return tea.Batch(
-		p.header.Init(),
-		p.sidebar.Init(),
-		p.chat.Init(),
-		p.editor.Init(),
-		p.splash.Init(),
-	)
-}
-
-func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	if p.session.ID != "" && p.app.AgentCoordinator != nil {
-		queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
-		if queueSize != p.promptQueue {
-			p.promptQueue = queueSize
-			cmds = append(cmds, p.SetSize(p.width, p.height))
-		}
-	}
-	switch msg := msg.(type) {
-	case tea.KeyboardEnhancementsMsg:
-		p.keyboardEnhancements = msg
-		return p, nil
-	case tea.MouseWheelMsg:
-		if p.compact {
-			msg.Y -= 1
-		}
-		if p.isMouseOverChat(msg.X, msg.Y) {
-			u, cmd := p.chat.Update(msg)
-			p.chat = u.(chat.MessageListCmp)
-			return p, cmd
-		}
-		return p, nil
-	case tea.MouseClickMsg:
-		if p.isOnboarding || p.isProjectInit {
-			return p, nil
-		}
-		if p.compact {
-			msg.Y -= 1
-		}
-		if p.isMouseOverChat(msg.X, msg.Y) {
-			p.focusedPane = PanelTypeChat
-			p.chat.Focus()
-			p.editor.Blur()
-		} else {
-			p.focusedPane = PanelTypeEditor
-			p.editor.Focus()
-			p.chat.Blur()
-		}
-		u, cmd := p.chat.Update(msg)
-		p.chat = u.(chat.MessageListCmp)
-		return p, cmd
-	case tea.MouseMotionMsg:
-		if p.compact {
-			msg.Y -= 1
-		}
-		if msg.Button == tea.MouseLeft {
-			u, cmd := p.chat.Update(msg)
-			p.chat = u.(chat.MessageListCmp)
-			return p, cmd
-		}
-		return p, nil
-	case tea.MouseReleaseMsg:
-		if p.isOnboarding || p.isProjectInit {
-			return p, nil
-		}
-		if p.compact {
-			msg.Y -= 1
-		}
-		if msg.Button == tea.MouseLeft {
-			u, cmd := p.chat.Update(msg)
-			p.chat = u.(chat.MessageListCmp)
-			return p, cmd
-		}
-		return p, nil
-	case chat.SelectionCopyMsg:
-		u, cmd := p.chat.Update(msg)
-		p.chat = u.(chat.MessageListCmp)
-		return p, cmd
-	case tea.WindowSizeMsg:
-		u, cmd := p.editor.Update(msg)
-		p.editor = u.(editor.Editor)
-		return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
-	case CancelTimerExpiredMsg:
-		p.isCanceling = false
-		return p, nil
-	case editor.OpenEditorMsg:
-		u, cmd := p.editor.Update(msg)
-		p.editor = u.(editor.Editor)
-		return p, cmd
-	case chat.SendMsg:
-		return p, p.sendMessage(msg.Text, msg.Attachments)
-	case chat.SessionSelectedMsg:
-		return p, p.setSession(msg)
-	case splash.SubmitAPIKeyMsg:
-		u, cmd := p.splash.Update(msg)
-		p.splash = u.(splash.Splash)
-		cmds = append(cmds, cmd)
-		return p, tea.Batch(cmds...)
-	case commands.ToggleCompactModeMsg:
-		p.forceCompact = !p.forceCompact
-		var cmd tea.Cmd
-		if p.forceCompact {
-			p.setCompactMode(true)
-			cmd = p.updateCompactConfig(true)
-		} else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
-			p.setCompactMode(false)
-			cmd = p.updateCompactConfig(false)
-		}
-		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
-	case commands.ToggleThinkingMsg:
-		return p, p.toggleThinking()
-	case commands.OpenReasoningDialogMsg:
-		return p, p.openReasoningDialog()
-	case reasoning.ReasoningEffortSelectedMsg:
-		return p, p.handleReasoningEffortSelected(msg.Effort)
-	case commands.OpenExternalEditorMsg:
-		u, cmd := p.editor.Update(msg)
-		p.editor = u.(editor.Editor)
-		return p, cmd
-	case pubsub.Event[session.Session]:
-		if msg.Payload.ID == p.session.ID {
-			prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
-			prevHasInProgress := p.hasInProgressTodo()
-			p.session = msg.Payload
-			newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
-			newHasInProgress := p.hasInProgressTodo()
-			if prevHasIncompleteTodos != newHasIncompleteTodos {
-				cmds = append(cmds, p.SetSize(p.width, p.height))
-			}
-			if !prevHasInProgress && newHasInProgress {
-				cmds = append(cmds, p.todoSpinner.Tick)
-			}
-		}
-		u, cmd := p.header.Update(msg)
-		p.header = u.(header.Header)
-		cmds = append(cmds, cmd)
-		u, cmd = p.sidebar.Update(msg)
-		p.sidebar = u.(sidebar.Sidebar)
-		cmds = append(cmds, cmd)
-		return p, tea.Batch(cmds...)
-	case chat.SessionClearedMsg:
-		u, cmd := p.header.Update(msg)
-		p.header = u.(header.Header)
-		cmds = append(cmds, cmd)
-		u, cmd = p.sidebar.Update(msg)
-		p.sidebar = u.(sidebar.Sidebar)
-		cmds = append(cmds, cmd)
-		u, cmd = p.chat.Update(msg)
-		p.chat = u.(chat.MessageListCmp)
-		cmds = append(cmds, cmd)
-		u, cmd = p.editor.Update(msg)
-		p.editor = u.(editor.Editor)
-		cmds = append(cmds, cmd)
-		return p, tea.Batch(cmds...)
-	case filepicker.FilePickedMsg,
-		completions.CompletionsClosedMsg,
-		completions.SelectCompletionMsg:
-		u, cmd := p.editor.Update(msg)
-		p.editor = u.(editor.Editor)
-		cmds = append(cmds, cmd)
-		return p, tea.Batch(cmds...)
-
-	case hyper.DeviceFlowCompletedMsg,
-		hyper.DeviceAuthInitiatedMsg,
-		hyper.DeviceFlowErrorMsg,
-		copilot.DeviceAuthInitiatedMsg,
-		copilot.DeviceFlowErrorMsg,
-		copilot.DeviceFlowCompletedMsg:
-		if p.focusedPane == PanelTypeSplash {
-			u, cmd := p.splash.Update(msg)
-			p.splash = u.(splash.Splash)
-			cmds = append(cmds, cmd)
-		}
-		return p, tea.Batch(cmds...)
-	case models.APIKeyStateChangeMsg:
-		if p.focusedPane == PanelTypeSplash {
-			u, cmd := p.splash.Update(msg)
-			p.splash = u.(splash.Splash)
-			cmds = append(cmds, cmd)
-		}
-		return p, tea.Batch(cmds...)
-	case pubsub.Event[message.Message],
-		anim.StepMsg,
-		spinner.TickMsg:
-		// Update todo spinner if agent is busy and we have in-progress todos
-		agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
-		if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy {
-			var cmd tea.Cmd
-			p.todoSpinner, cmd = p.todoSpinner.Update(msg)
-			cmds = append(cmds, cmd)
-		}
-		// Start spinner when agent becomes busy and we have in-progress todos
-		if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy {
-			cmds = append(cmds, p.todoSpinner.Tick)
-		}
-		if p.focusedPane == PanelTypeSplash {
-			u, cmd := p.splash.Update(msg)
-			p.splash = u.(splash.Splash)
-			cmds = append(cmds, cmd)
-		} else {
-			u, cmd := p.chat.Update(msg)
-			p.chat = u.(chat.MessageListCmp)
-			cmds = append(cmds, cmd)
-		}
-
-		return p, tea.Batch(cmds...)
-	case commands.ToggleYoloModeMsg:
-		// update the editor style
-		u, cmd := p.editor.Update(msg)
-		p.editor = u.(editor.Editor)
-		return p, cmd
-	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
-		u, cmd := p.sidebar.Update(msg)
-		p.sidebar = u.(sidebar.Sidebar)
-		cmds = append(cmds, cmd)
-		return p, tea.Batch(cmds...)
-	case pubsub.Event[permission.PermissionNotification]:
-		u, cmd := p.chat.Update(msg)
-		p.chat = u.(chat.MessageListCmp)
-		cmds = append(cmds, cmd)
-		return p, tea.Batch(cmds...)
-
-	case commands.CommandRunCustomMsg:
-		if p.app.AgentCoordinator.IsBusy() {
-			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
-		}
-
-		cmd := p.sendMessage(msg.Content, nil)
-		if cmd != nil {
-			return p, cmd
-		}
-	case splash.OnboardingCompleteMsg:
-		p.splashFullScreen = false
-		if b, _ := config.ProjectNeedsInitialization(); b {
-			p.splash.SetProjectInit(true)
-			p.splashFullScreen = true
-			return p, p.SetSize(p.width, p.height)
-		}
-		err := p.app.InitCoderAgent(context.TODO())
-		if err != nil {
-			return p, util.ReportError(err)
-		}
-		p.isOnboarding = false
-		p.isProjectInit = false
-		p.focusedPane = PanelTypeEditor
-		return p, p.SetSize(p.width, p.height)
-	case commands.NewSessionsMsg:
-		if p.app.AgentCoordinator.IsBusy() {
-			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
-		}
-		return p, p.newSession()
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, p.keyMap.NewSession):
-			// if we have no agent do nothing
-			if p.app.AgentCoordinator == nil {
-				return p, nil
-			}
-			if p.app.AgentCoordinator.IsBusy() {
-				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
-			}
-			return p, p.newSession()
-		case key.Matches(msg, p.keyMap.AddAttachment):
-			// Skip attachment handling during onboarding/splash screen
-			if p.focusedPane == PanelTypeSplash || p.isOnboarding {
-				u, cmd := p.splash.Update(msg)
-				p.splash = u.(splash.Splash)
-				return p, cmd
-			}
-			agentCfg := config.Get().Agents[config.AgentCoder]
-			model := config.Get().GetModelByType(agentCfg.Model)
-			if model == nil {
-				return p, util.ReportWarn("No model configured yet")
-			}
-			if model.SupportsImages {
-				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
-			} else {
-				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
-			}
-		case key.Matches(msg, p.keyMap.Tab):
-			if p.session.ID == "" {
-				u, cmd := p.splash.Update(msg)
-				p.splash = u.(splash.Splash)
-				return p, cmd
-			}
-			return p, p.changeFocus()
-		case key.Matches(msg, p.keyMap.Cancel):
-			if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
-				return p, p.cancel()
-			}
-		case key.Matches(msg, p.keyMap.Details):
-			p.toggleDetails()
-			return p, nil
-		case key.Matches(msg, p.keyMap.TogglePills):
-			if p.session.ID != "" {
-				return p, p.togglePillsExpanded()
-			}
-		case key.Matches(msg, p.keyMap.PillLeft):
-			if p.session.ID != "" && p.pillsExpanded {
-				return p, p.switchPillSection(-1)
-			}
-		case key.Matches(msg, p.keyMap.PillRight):
-			if p.session.ID != "" && p.pillsExpanded {
-				return p, p.switchPillSection(1)
-			}
-		}
-
-		switch p.focusedPane {
-		case PanelTypeChat:
-			u, cmd := p.chat.Update(msg)
-			p.chat = u.(chat.MessageListCmp)
-			cmds = append(cmds, cmd)
-		case PanelTypeEditor:
-			u, cmd := p.editor.Update(msg)
-			p.editor = u.(editor.Editor)
-			cmds = append(cmds, cmd)
-		case PanelTypeSplash:
-			u, cmd := p.splash.Update(msg)
-			p.splash = u.(splash.Splash)
-			cmds = append(cmds, cmd)
-		}
-	case tea.PasteMsg:
-		switch p.focusedPane {
-		case PanelTypeEditor:
-			u, cmd := p.editor.Update(msg)
-			p.editor = u.(editor.Editor)
-			cmds = append(cmds, cmd)
-			return p, tea.Batch(cmds...)
-		case PanelTypeChat:
-			u, cmd := p.chat.Update(msg)
-			p.chat = u.(chat.MessageListCmp)
-			cmds = append(cmds, cmd)
-			return p, tea.Batch(cmds...)
-		case PanelTypeSplash:
-			u, cmd := p.splash.Update(msg)
-			p.splash = u.(splash.Splash)
-			cmds = append(cmds, cmd)
-			return p, tea.Batch(cmds...)
-		}
-	}
-	return p, tea.Batch(cmds...)
-}
-
-func (p *chatPage) Cursor() *tea.Cursor {
-	if p.header.ShowingDetails() {
-		return nil
-	}
-	switch p.focusedPane {
-	case PanelTypeEditor:
-		return p.editor.Cursor()
-	case PanelTypeSplash:
-		return p.splash.Cursor()
-	default:
-		return nil
-	}
-}
-
-func (p *chatPage) View() string {
-	var chatView string
-	t := styles.CurrentTheme()
-
-	if p.session.ID == "" {
-		splashView := p.splash.View()
-		// Full screen during onboarding or project initialization
-		if p.splashFullScreen {
-			chatView = splashView
-		} else {
-			// Show splash + editor for new message state
-			editorView := p.editor.View()
-			chatView = lipgloss.JoinVertical(
-				lipgloss.Left,
-				t.S().Base.Render(splashView),
-				editorView,
-			)
-		}
-	} else {
-		messagesView := p.chat.View()
-		editorView := p.editor.View()
-
-		hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
-		hasQueue := p.promptQueue > 0
-		todosFocused := p.pillsExpanded && p.focusedPillSection == PillSectionTodos
-		queueFocused := p.pillsExpanded && p.focusedPillSection == PillSectionQueue
-
-		// Use spinner when agent is busy, otherwise show static icon
-		agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
-		inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon)
-		if agentBusy {
-			inProgressIcon = p.todoSpinner.View()
-		}
-
-		var pills []string
-		if hasIncompleteTodos {
-			pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, p.pillsExpanded, t))
-		}
-		if hasQueue {
-			pills = append(pills, queuePill(p.promptQueue, queueFocused, p.pillsExpanded, t))
-		}
-
-		var expandedList string
-		if p.pillsExpanded {
-			if todosFocused && hasIncompleteTodos {
-				expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth)
-			} else if queueFocused && hasQueue {
-				queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID)
-				expandedList = queueList(queueItems, t)
-			}
-		}
-
-		var pillsArea string
-		if len(pills) > 0 {
-			pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
-
-			// Add help hint for expanding/collapsing pills based on state.
-			var helpDesc string
-			if p.pillsExpanded {
-				helpDesc = "close"
-			} else {
-				helpDesc = "open"
-			}
-			// Style to match help section: keys in FgMuted, description in FgSubtle
-			helpKey := t.S().Base.Foreground(t.FgMuted).Render("ctrl+space")
-			helpText := t.S().Base.Foreground(t.FgSubtle).Render(helpDesc)
-			helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
-			pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
-
-			if expandedList != "" {
-				pillsArea = lipgloss.JoinVertical(
-					lipgloss.Left,
-					pillsRow,
-					expandedList,
-				)
-			} else {
-				pillsArea = pillsRow
-			}
-
-			pillsArea = t.S().Base.
-				MaxWidth(p.width).
-				MarginTop(1).
-				PaddingLeft(3).
-				Render(pillsArea)
-		}
-
-		if p.compact {
-			headerView := p.header.View()
-			views := []string{headerView, messagesView}
-			if pillsArea != "" {
-				views = append(views, pillsArea)
-			}
-			views = append(views, editorView)
-			chatView = lipgloss.JoinVertical(lipgloss.Left, views...)
-		} else {
-			sidebarView := p.sidebar.View()
-			var messagesColumn string
-			if pillsArea != "" {
-				messagesColumn = lipgloss.JoinVertical(
-					lipgloss.Left,
-					messagesView,
-					pillsArea,
-				)
-			} else {
-				messagesColumn = messagesView
-			}
-			messages := lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				messagesColumn,
-				sidebarView,
-			)
-			chatView = lipgloss.JoinVertical(
-				lipgloss.Left,
-				messages,
-				p.editor.View(),
-			)
-		}
-	}
-
-	layers := []*lipgloss.Layer{
-		lipgloss.NewLayer(chatView).X(0).Y(0),
-	}
-
-	if p.showingDetails {
-		style := t.S().Base.
-			Width(p.detailsWidth).
-			Border(lipgloss.RoundedBorder()).
-			BorderForeground(t.BorderFocus)
-		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
-		details := style.Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				p.sidebar.View(),
-				version,
-			),
-		)
-		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
-	}
-	canvas := lipgloss.NewCompositor(layers...)
-	return canvas.Render()
-}
-
-func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
-	return func() tea.Msg {
-		err := config.Get().SetCompactMode(compact)
-		if err != nil {
-			return util.InfoMsg{
-				Type: util.InfoTypeError,
-				Msg:  "Failed to update compact mode configuration: " + err.Error(),
-			}
-		}
-		return nil
-	}
-}
-
-func (p *chatPage) toggleThinking() tea.Cmd {
-	return func() tea.Msg {
-		cfg := config.Get()
-		agentCfg := cfg.Agents[config.AgentCoder]
-		currentModel := cfg.Models[agentCfg.Model]
-
-		// Toggle the thinking mode
-		currentModel.Think = !currentModel.Think
-		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
-			return util.InfoMsg{
-				Type: util.InfoTypeError,
-				Msg:  "Failed to update thinking mode: " + err.Error(),
-			}
-		}
-
-		// Update the agent with the new configuration
-		go p.app.UpdateAgentModel(context.TODO())
-
-		status := "disabled"
-		if currentModel.Think {
-			status = "enabled"
-		}
-		return util.InfoMsg{
-			Type: util.InfoTypeInfo,
-			Msg:  "Thinking mode " + status,
-		}
-	}
-}
-
-func (p *chatPage) openReasoningDialog() tea.Cmd {
-	return func() tea.Msg {
-		cfg := config.Get()
-		agentCfg := cfg.Agents[config.AgentCoder]
-		model := cfg.GetModelByType(agentCfg.Model)
-		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
-
-		if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
-			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
-			return dialogs.OpenDialogMsg{
-				Model: reasoning.NewReasoningDialog(),
-			}
-		}
-		return nil
-	}
-}
-
-func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
-	return func() tea.Msg {
-		cfg := config.Get()
-		agentCfg := cfg.Agents[config.AgentCoder]
-		currentModel := cfg.Models[agentCfg.Model]
-
-		// Update the model configuration
-		currentModel.ReasoningEffort = effort
-		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
-			return util.InfoMsg{
-				Type: util.InfoTypeError,
-				Msg:  "Failed to update reasoning effort: " + err.Error(),
-			}
-		}
-
-		// Update the agent with the new configuration
-		if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
-			return util.InfoMsg{
-				Type: util.InfoTypeError,
-				Msg:  "Failed to update reasoning effort: " + err.Error(),
-			}
-		}
-
-		return util.InfoMsg{
-			Type: util.InfoTypeInfo,
-			Msg:  "Reasoning effort set to " + effort,
-		}
-	}
-}
-
-func (p *chatPage) setCompactMode(compact bool) {
-	if p.compact == compact {
-		return
-	}
-	p.compact = compact
-	if compact {
-		p.sidebar.SetCompactMode(true)
-	} else {
-		p.setShowDetails(false)
-	}
-}
-
-func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
-	if p.forceCompact {
-		return
-	}
-	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
-		p.setCompactMode(true)
-	}
-	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
-		p.setCompactMode(false)
-	}
-}
-
-func (p *chatPage) SetSize(width, height int) tea.Cmd {
-	p.handleCompactMode(width, height)
-	p.width = width
-	p.height = height
-	var cmds []tea.Cmd
-
-	if p.session.ID == "" {
-		if p.splashFullScreen {
-			cmds = append(cmds, p.splash.SetSize(width, height))
-		} else {
-			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
-			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
-			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
-		}
-	} else {
-		hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
-		hasQueue := p.promptQueue > 0
-		hasPills := hasIncompleteTodos || hasQueue
-
-		pillsAreaHeight := 0
-		if hasPills {
-			pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top
-			if p.pillsExpanded {
-				if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos {
-					pillsAreaHeight += len(p.session.Todos)
-				} else if p.focusedPillSection == PillSectionQueue && hasQueue {
-					pillsAreaHeight += p.promptQueue
-				}
-			}
-		}
-
-		if p.compact {
-			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight))
-			p.detailsWidth = width - DetailsPositioning
-			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
-			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
-			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
-		} else {
-			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight))
-			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
-			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
-		}
-		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
-	}
-	return tea.Batch(cmds...)
-}
-
-func (p *chatPage) newSession() tea.Cmd {
-	if p.session.ID == "" {
-		return nil
-	}
-
-	p.session = session.Session{}
-	p.focusedPane = PanelTypeEditor
-	p.editor.Focus()
-	p.chat.Blur()
-	p.isCanceling = false
-	return tea.Batch(
-		util.CmdHandler(chat.SessionClearedMsg{}),
-		p.SetSize(p.width, p.height),
-	)
-}
-
-func (p *chatPage) setSession(sess session.Session) tea.Cmd {
-	if p.session.ID == sess.ID {
-		return nil
-	}
-
-	var cmds []tea.Cmd
-	p.session = sess
-
-	if p.hasInProgressTodo() {
-		cmds = append(cmds, p.todoSpinner.Tick)
-	}
-
-	cmds = append(cmds, p.SetSize(p.width, p.height))
-	cmds = append(cmds, p.chat.SetSession(sess))
-	cmds = append(cmds, p.sidebar.SetSession(sess))
-	cmds = append(cmds, p.header.SetSession(sess))
-	cmds = append(cmds, p.editor.SetSession(sess))
-
-	return tea.Sequence(cmds...)
-}
-
-func (p *chatPage) changeFocus() tea.Cmd {
-	if p.session.ID == "" {
-		return nil
-	}
-
-	switch p.focusedPane {
-	case PanelTypeEditor:
-		p.focusedPane = PanelTypeChat
-		p.chat.Focus()
-		p.editor.Blur()
-	case PanelTypeChat:
-		p.focusedPane = PanelTypeEditor
-		p.editor.Focus()
-		p.chat.Blur()
-	}
-	return nil
-}
-
-func (p *chatPage) togglePillsExpanded() tea.Cmd {
-	hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0
-	if !hasPills {
-		return nil
-	}
-	p.pillsExpanded = !p.pillsExpanded
-	if p.pillsExpanded {
-		if hasIncompleteTodos(p.session.Todos) {
-			p.focusedPillSection = PillSectionTodos
-		} else {
-			p.focusedPillSection = PillSectionQueue
-		}
-	}
-	return p.SetSize(p.width, p.height)
-}
-
-func (p *chatPage) switchPillSection(dir int) tea.Cmd {
-	if !p.pillsExpanded {
-		return nil
-	}
-	hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
-	hasQueue := p.promptQueue > 0
-
-	if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos {
-		p.focusedPillSection = PillSectionTodos
-		return p.SetSize(p.width, p.height)
-	}
-	if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue {
-		p.focusedPillSection = PillSectionQueue
-		return p.SetSize(p.width, p.height)
-	}
-	return nil
-}
-
-func (p *chatPage) cancel() tea.Cmd {
-	if p.isCanceling {
-		p.isCanceling = false
-		if p.app.AgentCoordinator != nil {
-			p.app.AgentCoordinator.Cancel(p.session.ID)
-		}
-		return nil
-	}
-
-	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
-		p.app.AgentCoordinator.ClearQueue(p.session.ID)
-		return nil
-	}
-	p.isCanceling = true
-	return cancelTimerCmd()
-}
-
-func (p *chatPage) setShowDetails(show bool) {
-	p.showingDetails = show
-	p.header.SetDetailsOpen(p.showingDetails)
-	if !p.compact {
-		p.sidebar.SetCompactMode(false)
-	}
-}
-
-func (p *chatPage) toggleDetails() {
-	if p.session.ID == "" || !p.compact {
-		return
-	}
-	p.setShowDetails(!p.showingDetails)
-}
-
-func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
-	session := p.session
-	var cmds []tea.Cmd
-	if p.session.ID == "" {
-		// XXX: The second argument here is the session name, which we leave
-		// blank as it will be auto-generated. Ideally, we remove the need for
-		// that argument entirely.
-		newSession, err := p.app.Sessions.Create(context.Background(), "")
-		if err != nil {
-			return util.ReportError(err)
-		}
-		session = newSession
-		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
-	}
-	if p.app.AgentCoordinator == nil {
-		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
-	}
-	cmds = append(cmds, p.chat.GoToBottom())
-	cmds = append(cmds, func() tea.Msg {
-		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
-		if err != nil {
-			isCancelErr := errors.Is(err, context.Canceled)
-			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
-			if isCancelErr || isPermissionErr {
-				return nil
-			}
-			return util.InfoMsg{
-				Type: util.InfoTypeError,
-				Msg:  err.Error(),
-			}
-		}
-		return nil
-	})
-	return tea.Batch(cmds...)
-}
-
-func (p *chatPage) Bindings() []key.Binding {
-	bindings := []key.Binding{
-		p.keyMap.NewSession,
-		p.keyMap.AddAttachment,
-	}
-	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
-		cancelBinding := p.keyMap.Cancel
-		if p.isCanceling {
-			cancelBinding = key.NewBinding(
-				key.WithKeys("esc", "alt+esc"),
-				key.WithHelp("esc", "press again to cancel"),
-			)
-		}
-		bindings = append([]key.Binding{cancelBinding}, bindings...)
-	}
-
-	switch p.focusedPane {
-	case PanelTypeChat:
-		bindings = append([]key.Binding{
-			key.NewBinding(
-				key.WithKeys("tab"),
-				key.WithHelp("tab", "focus editor"),
-			),
-		}, bindings...)
-		bindings = append(bindings, p.chat.Bindings()...)
-	case PanelTypeEditor:
-		bindings = append([]key.Binding{
-			key.NewBinding(
-				key.WithKeys("tab"),
-				key.WithHelp("tab", "focus chat"),
-			),
-		}, bindings...)
-		bindings = append(bindings, p.editor.Bindings()...)
-	case PanelTypeSplash:
-		bindings = append(bindings, p.splash.Bindings()...)
-	}
-
-	return bindings
-}
-
-func (p *chatPage) Help() help.KeyMap {
-	var shortList []key.Binding
-	var fullList [][]key.Binding
-	switch {
-	case p.isOnboarding:
-		switch {
-		case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
-			shortList = append(shortList,
-				key.NewBinding(
-					key.WithKeys("enter"),
-					key.WithHelp("enter", "copy url & open signup"),
-				),
-				key.NewBinding(
-					key.WithKeys("c"),
-					key.WithHelp("c", "copy url"),
-				),
-			)
-		default:
-			shortList = append(shortList,
-				key.NewBinding(
-					key.WithKeys("enter"),
-					key.WithHelp("enter", "submit"),
-				),
-			)
-		}
-		shortList = append(shortList,
-			// Quit
-			key.NewBinding(
-				key.WithKeys("ctrl+c"),
-				key.WithHelp("ctrl+c", "quit"),
-			),
-		)
-		// keep them the same
-		for _, v := range shortList {
-			fullList = append(fullList, []key.Binding{v})
-		}
-	case p.isOnboarding && !p.splash.IsShowingAPIKey():
-		shortList = append(shortList,
-			// Choose model
-			key.NewBinding(
-				key.WithKeys("up", "down"),
-				key.WithHelp("↑/↓", "choose"),
-			),
-			// Accept selection
-			key.NewBinding(
-				key.WithKeys("enter", "ctrl+y"),
-				key.WithHelp("enter", "accept"),
-			),
-			// Quit
-			key.NewBinding(
-				key.WithKeys("ctrl+c"),
-				key.WithHelp("ctrl+c", "quit"),
-			),
-		)
-		// keep them the same
-		for _, v := range shortList {
-			fullList = append(fullList, []key.Binding{v})
-		}
-	case p.isOnboarding && p.splash.IsShowingAPIKey():
-		if p.splash.IsAPIKeyValid() {
-			shortList = append(shortList,
-				key.NewBinding(
-					key.WithKeys("enter"),
-					key.WithHelp("enter", "continue"),
-				),
-			)
-		} else {
-			shortList = append(shortList,
-				// Go back
-				key.NewBinding(
-					key.WithKeys("esc", "alt+esc"),
-					key.WithHelp("esc", "back"),
-				),
-			)
-		}
-		shortList = append(shortList,
-			// Quit
-			key.NewBinding(
-				key.WithKeys("ctrl+c"),
-				key.WithHelp("ctrl+c", "quit"),
-			),
-		)
-		// keep them the same
-		for _, v := range shortList {
-			fullList = append(fullList, []key.Binding{v})
-		}
-	case p.isProjectInit:
-		shortList = append(shortList,
-			key.NewBinding(
-				key.WithKeys("ctrl+c"),
-				key.WithHelp("ctrl+c", "quit"),
-			),
-		)
-		// keep them the same
-		for _, v := range shortList {
-			fullList = append(fullList, []key.Binding{v})
-		}
-	default:
-		if p.editor.IsCompletionsOpen() {
-			shortList = append(shortList,
-				key.NewBinding(
-					key.WithKeys("tab", "enter"),
-					key.WithHelp("tab/enter", "complete"),
-				),
-				key.NewBinding(
-					key.WithKeys("esc", "alt+esc"),
-					key.WithHelp("esc", "cancel"),
-				),
-				key.NewBinding(
-					key.WithKeys("up", "down"),
-					key.WithHelp("↑/↓", "choose"),
-				),
-			)
-			for _, v := range shortList {
-				fullList = append(fullList, []key.Binding{v})
-			}
-			return core.NewSimpleHelp(shortList, fullList)
-		}
-		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
-			cancelBinding := key.NewBinding(
-				key.WithKeys("esc", "alt+esc"),
-				key.WithHelp("esc", "cancel"),
-			)
-			if p.isCanceling {
-				cancelBinding = key.NewBinding(
-					key.WithKeys("esc", "alt+esc"),
-					key.WithHelp("esc", "press again to cancel"),
-				)
-			}
-			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
-				cancelBinding = key.NewBinding(
-					key.WithKeys("esc", "alt+esc"),
-					key.WithHelp("esc", "clear queue"),
-				)
-			}
-			shortList = append(shortList, cancelBinding)
-			fullList = append(fullList,
-				[]key.Binding{
-					cancelBinding,
-				},
-			)
-		}
-		globalBindings := []key.Binding{}
-		// we are in a session
-		if p.session.ID != "" {
-			var tabKey key.Binding
-			switch p.focusedPane {
-			case PanelTypeEditor:
-				tabKey = key.NewBinding(
-					key.WithKeys("tab"),
-					key.WithHelp("tab", "focus chat"),
-				)
-			case PanelTypeChat:
-				tabKey = key.NewBinding(
-					key.WithKeys("tab"),
-					key.WithHelp("tab", "focus editor"),
-				)
-			default:
-				tabKey = key.NewBinding(
-					key.WithKeys("tab"),
-					key.WithHelp("tab", "focus chat"),
-				)
-			}
-			shortList = append(shortList, tabKey)
-			globalBindings = append(globalBindings, tabKey)
-
-			// Show left/right to switch sections when expanded and both exist
-			hasTodos := hasIncompleteTodos(p.session.Todos)
-			hasQueue := p.promptQueue > 0
-			if p.pillsExpanded && hasTodos && hasQueue {
-				shortList = append(shortList, p.keyMap.PillLeft)
-				globalBindings = append(globalBindings, p.keyMap.PillLeft)
-			}
-		}
-		commandsBinding := key.NewBinding(
-			key.WithKeys("ctrl+p"),
-			key.WithHelp("ctrl+p", "commands"),
-		)
-		if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
-			commandsBinding.SetHelp("/ or ctrl+p", "commands")
-		}
-		modelsBinding := key.NewBinding(
-			key.WithKeys("ctrl+m", "ctrl+l"),
-			key.WithHelp("ctrl+l", "models"),
-		)
-		if p.keyboardEnhancements.Flags > 0 {
-			// non-zero flags mean we have at least key disambiguation
-			modelsBinding.SetHelp("ctrl+m", "models")
-		}
-		helpBinding := key.NewBinding(
-			key.WithKeys("ctrl+g"),
-			key.WithHelp("ctrl+g", "more"),
-		)
-		globalBindings = append(globalBindings, commandsBinding, modelsBinding)
-		globalBindings = append(globalBindings,
-			key.NewBinding(
-				key.WithKeys("ctrl+s"),
-				key.WithHelp("ctrl+s", "sessions"),
-			),
-		)
-		if p.session.ID != "" {
-			globalBindings = append(globalBindings,
-				key.NewBinding(
-					key.WithKeys("ctrl+n"),
-					key.WithHelp("ctrl+n", "new sessions"),
-				))
-		}
-		shortList = append(shortList,
-			// Commands
-			commandsBinding,
-			modelsBinding,
-		)
-		fullList = append(fullList, globalBindings)
-
-		switch p.focusedPane {
-		case PanelTypeChat:
-			shortList = append(shortList,
-				key.NewBinding(
-					key.WithKeys("up", "down"),
-					key.WithHelp("↑↓", "scroll"),
-				),
-				messages.CopyKey,
-			)
-			fullList = append(fullList,
-				[]key.Binding{
-					key.NewBinding(
-						key.WithKeys("up", "down"),
-						key.WithHelp("↑↓", "scroll"),
-					),
-					key.NewBinding(
-						key.WithKeys("shift+up", "shift+down"),
-						key.WithHelp("shift+↑↓", "next/prev item"),
-					),
-					key.NewBinding(
-						key.WithKeys("pgup", "b"),
-						key.WithHelp("b/pgup", "page up"),
-					),
-					key.NewBinding(
-						key.WithKeys("pgdown", " ", "f"),
-						key.WithHelp("f/pgdn", "page down"),
-					),
-				},
-				[]key.Binding{
-					key.NewBinding(
-						key.WithKeys("u"),
-						key.WithHelp("u", "half page up"),
-					),
-					key.NewBinding(
-						key.WithKeys("d"),
-						key.WithHelp("d", "half page down"),
-					),
-					key.NewBinding(
-						key.WithKeys("g", "home"),
-						key.WithHelp("g", "home"),
-					),
-					key.NewBinding(
-						key.WithKeys("G", "end"),
-						key.WithHelp("G", "end"),
-					),
-				},
-				[]key.Binding{
-					messages.CopyKey,
-					messages.ClearSelectionKey,
-				},
-			)
-		case PanelTypeEditor:
-			newLineBinding := key.NewBinding(
-				key.WithKeys("shift+enter", "ctrl+j"),
-				// "ctrl+j" is a common keybinding for newline in many editors. If
-				// the terminal supports "shift+enter", we substitute the help text
-				// to reflect that.
-				key.WithHelp("ctrl+j", "newline"),
-			)
-			if p.keyboardEnhancements.Flags > 0 {
-				// Non-zero flags mean we have at least key disambiguation.
-				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
-			}
-			shortList = append(shortList, newLineBinding)
-			fullList = append(fullList,
-				[]key.Binding{
-					newLineBinding,
-					key.NewBinding(
-						key.WithKeys("ctrl+f"),
-						key.WithHelp("ctrl+f", "add image"),
-					),
-					key.NewBinding(
-						key.WithKeys("@"),
-						key.WithHelp("@", "mention file"),
-					),
-					key.NewBinding(
-						key.WithKeys("ctrl+o"),
-						key.WithHelp("ctrl+o", "open editor"),
-					),
-				})
-
-			if p.editor.HasAttachments() {
-				fullList = append(fullList, []key.Binding{
-					key.NewBinding(
-						key.WithKeys("ctrl+r"),
-						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
-					),
-					key.NewBinding(
-						key.WithKeys("ctrl+r", "r"),
-						key.WithHelp("ctrl+r+r", "delete all attachments"),
-					),
-					key.NewBinding(
-						key.WithKeys("esc", "alt+esc"),
-						key.WithHelp("esc", "cancel delete mode"),
-					),
-				})
-			}
-		}
-		shortList = append(shortList,
-			// Quit
-			key.NewBinding(
-				key.WithKeys("ctrl+c"),
-				key.WithHelp("ctrl+c", "quit"),
-			),
-			// Help
-			helpBinding,
-		)
-		fullList = append(fullList, []key.Binding{
-			key.NewBinding(
-				key.WithKeys("ctrl+g"),
-				key.WithHelp("ctrl+g", "less"),
-			),
-		})
-	}
-
-	return core.NewSimpleHelp(shortList, fullList)
-}
-
-func (p *chatPage) IsChatFocused() bool {
-	return p.focusedPane == PanelTypeChat
-}
-
-// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
-// Returns true if the mouse is over the chat area, false otherwise.
-func (p *chatPage) isMouseOverChat(x, y int) bool {
-	// No session means no chat area
-	if p.session.ID == "" {
-		return false
-	}
-
-	var chatX, chatY, chatWidth, chatHeight int
-
-	if p.compact {
-		// In compact mode: chat area starts after header and spans full width
-		chatX = 0
-		chatY = HeaderHeight
-		chatWidth = p.width
-		chatHeight = p.height - EditorHeight - HeaderHeight
-	} else {
-		// In non-compact mode: chat area spans from left edge to sidebar
-		chatX = 0
-		chatY = 0
-		chatWidth = p.width - SideBarWidth
-		chatHeight = p.height - EditorHeight
-	}
-
-	// Check if mouse coordinates are within chat bounds
-	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
-}
-
-func (p *chatPage) hasInProgressTodo() bool {
-	for _, todo := range p.session.Todos {
-		if todo.Status == session.TodoStatusInProgress {
-			return true
-		}
-	}
-	return false
-}

internal/tui/page/chat/keys.go 🔗

@@ -1,53 +0,0 @@
-package chat
-
-import (
-	"charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	NewSession    key.Binding
-	AddAttachment key.Binding
-	Cancel        key.Binding
-	Tab           key.Binding
-	Details       key.Binding
-	TogglePills   key.Binding
-	PillLeft      key.Binding
-	PillRight     key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		NewSession: key.NewBinding(
-			key.WithKeys("ctrl+n"),
-			key.WithHelp("ctrl+n", "new session"),
-		),
-		AddAttachment: key.NewBinding(
-			key.WithKeys("ctrl+f"),
-			key.WithHelp("ctrl+f", "add attachment"),
-		),
-		Cancel: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", "change focus"),
-		),
-		Details: key.NewBinding(
-			key.WithKeys("ctrl+d"),
-			key.WithHelp("ctrl+d", "toggle details"),
-		),
-		TogglePills: key.NewBinding(
-			key.WithKeys("ctrl+space"),
-			key.WithHelp("ctrl+space", "toggle tasks"),
-		),
-		PillLeft: key.NewBinding(
-			key.WithKeys("left"),
-			key.WithHelp("←/→", "switch section"),
-		),
-		PillRight: key.NewBinding(
-			key.WithKeys("right"),
-			key.WithHelp("←/→", "switch section"),
-		),
-	}
-}

internal/tui/page/chat/pills.go 🔗

@@ -1,125 +0,0 @@
-package chat
-
-import (
-	"fmt"
-	"strings"
-
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/todos"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-func hasIncompleteTodos(todos []session.Todo) bool {
-	for _, todo := range todos {
-		if todo.Status != session.TodoStatusCompleted {
-			return true
-		}
-	}
-	return false
-}
-
-const (
-	pillHeightWithBorder  = 3
-	maxTaskDisplayLength  = 40
-	maxQueueDisplayLength = 60
-)
-
-func queuePill(queue int, focused, pillsPanelFocused bool, t *styles.Theme) string {
-	if queue <= 0 {
-		return ""
-	}
-	triangles := styles.ForegroundGrad("▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Accent)
-	if queue < 10 {
-		triangles = triangles[:queue]
-	}
-
-	content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
-
-	style := t.S().Base.PaddingLeft(1).PaddingRight(1)
-	if !pillsPanelFocused || focused {
-		style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay)
-	} else {
-		style = style.BorderStyle(lipgloss.HiddenBorder())
-	}
-	return style.Render(content)
-}
-
-func todoPill(todos []session.Todo, spinnerView string, focused, pillsPanelFocused bool, t *styles.Theme) string {
-	if !hasIncompleteTodos(todos) {
-		return ""
-	}
-
-	completed := 0
-	var currentTodo *session.Todo
-	for i := range todos {
-		switch todos[i].Status {
-		case session.TodoStatusCompleted:
-			completed++
-		case session.TodoStatusInProgress:
-			if currentTodo == nil {
-				currentTodo = &todos[i]
-			}
-		}
-	}
-
-	total := len(todos)
-
-	label := "To-Do"
-	progress := t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("%d/%d", completed, total))
-
-	var content string
-	if pillsPanelFocused {
-		content = fmt.Sprintf("%s %s", label, progress)
-	} else if currentTodo != nil {
-		taskText := currentTodo.Content
-		if currentTodo.ActiveForm != "" {
-			taskText = currentTodo.ActiveForm
-		}
-		if len(taskText) > maxTaskDisplayLength {
-			taskText = taskText[:maxTaskDisplayLength-1] + "…"
-		}
-		task := t.S().Base.Foreground(t.FgSubtle).Render(taskText)
-		content = fmt.Sprintf("%s %s %s  %s", spinnerView, label, progress, task)
-	} else {
-		content = fmt.Sprintf("%s %s", label, progress)
-	}
-
-	style := t.S().Base.PaddingLeft(1).PaddingRight(1)
-	if !pillsPanelFocused || focused {
-		style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay)
-	} else {
-		style = style.BorderStyle(lipgloss.HiddenBorder())
-	}
-	return style.Render(content)
-}
-
-func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Theme, width int) string {
-	return todos.FormatTodosList(sessionTodos, spinnerView, t, width)
-}
-
-func queueList(queueItems []string, t *styles.Theme) string {
-	if len(queueItems) == 0 {
-		return ""
-	}
-
-	var lines []string
-	for _, item := range queueItems {
-		text := item
-		if len(text) > maxQueueDisplayLength {
-			text = text[:maxQueueDisplayLength-1] + "…"
-		}
-		prefix := t.S().Base.Foreground(t.FgMuted).Render("  •") + " "
-		lines = append(lines, prefix+t.S().Base.Foreground(t.FgMuted).Render(text))
-	}
-
-	return strings.Join(lines, "\n")
-}
-
-func sectionLine(availableWidth int, t *styles.Theme) string {
-	if availableWidth <= 0 {
-		return ""
-	}
-	line := strings.Repeat("─", availableWidth)
-	return t.S().Base.Foreground(t.Border).Render(line)
-}

internal/tui/page/page.go 🔗

@@ -1,8 +0,0 @@
-package page
-
-type PageID string
-
-// PageChangeMsg is used to change the current page
-type PageChangeMsg struct {
-	ID PageID
-}

internal/tui/styles/charmtone.go 🔗

@@ -1,83 +0,0 @@
-package styles
-
-import (
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/x/exp/charmtone"
-)
-
-func NewCharmtoneTheme() *Theme {
-	t := &Theme{
-		Name:   "charmtone",
-		IsDark: true,
-
-		Primary:   charmtone.Charple,
-		Secondary: charmtone.Dolly,
-		Tertiary:  charmtone.Bok,
-		Accent:    charmtone.Zest,
-
-		// Backgrounds
-		BgBase:        charmtone.Pepper,
-		BgBaseLighter: charmtone.BBQ,
-		BgSubtle:      charmtone.Charcoal,
-		BgOverlay:     charmtone.Iron,
-
-		// Foregrounds
-		FgBase:      charmtone.Ash,
-		FgMuted:     charmtone.Squid,
-		FgHalfMuted: charmtone.Smoke,
-		FgSubtle:    charmtone.Oyster,
-		FgSelected:  charmtone.Salt,
-
-		// Borders
-		Border:      charmtone.Charcoal,
-		BorderFocus: charmtone.Charple,
-
-		// Status
-		Success: charmtone.Guac,
-		Error:   charmtone.Sriracha,
-		Warning: charmtone.Zest,
-		Info:    charmtone.Malibu,
-
-		// Colors
-		White: charmtone.Butter,
-
-		BlueLight: charmtone.Sardine,
-		BlueDark:  charmtone.Damson,
-		Blue:      charmtone.Malibu,
-
-		Yellow: charmtone.Mustard,
-		Citron: charmtone.Citron,
-
-		Green:      charmtone.Julep,
-		GreenDark:  charmtone.Guac,
-		GreenLight: charmtone.Bok,
-
-		Red:      charmtone.Coral,
-		RedDark:  charmtone.Sriracha,
-		RedLight: charmtone.Salmon,
-		Cherry:   charmtone.Cherry,
-	}
-
-	// Text selection.
-	t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
-
-	// LSP and MCP status.
-	t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
-	t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
-	t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
-	t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
-
-	// Editor: Yolo Mode.
-	t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
-	t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
-	t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
-	t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid)
-
-	// oAuth Chooser.
-	t.AuthBorderSelected = lipgloss.NewStyle().BorderForeground(charmtone.Guac)
-	t.AuthTextSelected = lipgloss.NewStyle().Foreground(charmtone.Julep)
-	t.AuthBorderUnselected = lipgloss.NewStyle().BorderForeground(charmtone.Iron)
-	t.AuthTextUnselected = lipgloss.NewStyle().Foreground(charmtone.Squid)
-
-	return t
-}

internal/tui/styles/chroma.go 🔗

@@ -1,79 +0,0 @@
-package styles
-
-import (
-	"charm.land/glamour/v2/ansi"
-	"github.com/alecthomas/chroma/v2"
-)
-
-func chromaStyle(style ansi.StylePrimitive) string {
-	var s string
-
-	if style.Color != nil {
-		s = *style.Color
-	}
-	if style.BackgroundColor != nil {
-		if s != "" {
-			s += " "
-		}
-		s += "bg:" + *style.BackgroundColor
-	}
-	if style.Italic != nil && *style.Italic {
-		if s != "" {
-			s += " "
-		}
-		s += "italic"
-	}
-	if style.Bold != nil && *style.Bold {
-		if s != "" {
-			s += " "
-		}
-		s += "bold"
-	}
-	if style.Underline != nil && *style.Underline {
-		if s != "" {
-			s += " "
-		}
-		s += "underline"
-	}
-
-	return s
-}
-
-func GetChromaTheme() chroma.StyleEntries {
-	t := CurrentTheme()
-	rules := t.S().Markdown.CodeBlock
-
-	return chroma.StyleEntries{
-		chroma.Text:                chromaStyle(rules.Chroma.Text),
-		chroma.Error:               chromaStyle(rules.Chroma.Error),
-		chroma.Comment:             chromaStyle(rules.Chroma.Comment),
-		chroma.CommentPreproc:      chromaStyle(rules.Chroma.CommentPreproc),
-		chroma.Keyword:             chromaStyle(rules.Chroma.Keyword),
-		chroma.KeywordReserved:     chromaStyle(rules.Chroma.KeywordReserved),
-		chroma.KeywordNamespace:    chromaStyle(rules.Chroma.KeywordNamespace),
-		chroma.KeywordType:         chromaStyle(rules.Chroma.KeywordType),
-		chroma.Operator:            chromaStyle(rules.Chroma.Operator),
-		chroma.Punctuation:         chromaStyle(rules.Chroma.Punctuation),
-		chroma.Name:                chromaStyle(rules.Chroma.Name),
-		chroma.NameBuiltin:         chromaStyle(rules.Chroma.NameBuiltin),
-		chroma.NameTag:             chromaStyle(rules.Chroma.NameTag),
-		chroma.NameAttribute:       chromaStyle(rules.Chroma.NameAttribute),
-		chroma.NameClass:           chromaStyle(rules.Chroma.NameClass),
-		chroma.NameConstant:        chromaStyle(rules.Chroma.NameConstant),
-		chroma.NameDecorator:       chromaStyle(rules.Chroma.NameDecorator),
-		chroma.NameException:       chromaStyle(rules.Chroma.NameException),
-		chroma.NameFunction:        chromaStyle(rules.Chroma.NameFunction),
-		chroma.NameOther:           chromaStyle(rules.Chroma.NameOther),
-		chroma.Literal:             chromaStyle(rules.Chroma.Literal),
-		chroma.LiteralNumber:       chromaStyle(rules.Chroma.LiteralNumber),
-		chroma.LiteralDate:         chromaStyle(rules.Chroma.LiteralDate),
-		chroma.LiteralString:       chromaStyle(rules.Chroma.LiteralString),
-		chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
-		chroma.GenericDeleted:      chromaStyle(rules.Chroma.GenericDeleted),
-		chroma.GenericEmph:         chromaStyle(rules.Chroma.GenericEmph),
-		chroma.GenericInserted:     chromaStyle(rules.Chroma.GenericInserted),
-		chroma.GenericStrong:       chromaStyle(rules.Chroma.GenericStrong),
-		chroma.GenericSubheading:   chromaStyle(rules.Chroma.GenericSubheading),
-		chroma.Background:          chromaStyle(rules.Chroma.Background),
-	}
-}

internal/tui/styles/icons.go 🔗

@@ -1,48 +0,0 @@
-package styles
-
-const (
-	CheckIcon         string = "✓"
-	ErrorIcon         string = "×"
-	WarningIcon       string = "⚠"
-	InfoIcon          string = "ⓘ"
-	HintIcon          string = "∵"
-	SpinnerIcon       string = "..."
-	ArrowRightIcon    string = "→"
-	CenterSpinnerIcon string = "⋯"
-	LoadingIcon       string = "⟳"
-	ImageIcon         string = "■"
-	TextIcon          string = "☰"
-	ModelIcon         string = "◇"
-
-	// Tool call icons
-	ToolPending string = "●"
-	ToolSuccess string = "✓"
-	ToolError   string = "×"
-
-	BorderThin  string = "│"
-	BorderThick string = "▌"
-
-	// Todo icons
-	TodoCompletedIcon string = "✓"
-	TodoPendingIcon   string = "•"
-)
-
-var SelectionIgnoreIcons = []string{
-	// CheckIcon,
-	// ErrorIcon,
-	// WarningIcon,
-	// InfoIcon,
-	// HintIcon,
-	// SpinnerIcon,
-	// LoadingIcon,
-	// DocumentIcon,
-	// ModelIcon,
-	//
-	// // Tool call icons
-	// ToolPending,
-	// ToolSuccess,
-	// ToolError,
-
-	BorderThin,
-	BorderThick,
-}

internal/tui/styles/markdown.go 🔗

@@ -1,205 +0,0 @@
-package styles
-
-import (
-	"fmt"
-	"image/color"
-
-	"charm.land/glamour/v2"
-	"charm.land/glamour/v2/ansi"
-)
-
-// lipglossColorToHex converts a color.Color to hex string
-func lipglossColorToHex(c color.Color) string {
-	r, g, b, _ := c.RGBA()
-	return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
-}
-
-// Helper functions for style pointers
-func boolPtr(b bool) *bool       { return &b }
-func stringPtr(s string) *string { return &s }
-func uintPtr(u uint) *uint       { return &u }
-
-// returns a glamour TermRenderer configured with the current theme
-func GetMarkdownRenderer(width int) *glamour.TermRenderer {
-	t := CurrentTheme()
-	r, _ := glamour.NewTermRenderer(
-		glamour.WithStyles(t.S().Markdown),
-		glamour.WithWordWrap(width),
-	)
-	return r
-}
-
-// returns a glamour TermRenderer with no colors (plain text with structure)
-func GetPlainMarkdownRenderer(width int) *glamour.TermRenderer {
-	r, _ := glamour.NewTermRenderer(
-		glamour.WithStyles(PlainMarkdownStyle()),
-		glamour.WithWordWrap(width),
-	)
-	return r
-}
-
-// PlainMarkdownStyle returns a glamour style config with no colors
-func PlainMarkdownStyle() ansi.StyleConfig {
-	t := CurrentTheme()
-	bgColor := stringPtr(lipglossColorToHex(t.BgBaseLighter))
-	fgColor := stringPtr(lipglossColorToHex(t.FgMuted))
-	return ansi.StyleConfig{
-		Document: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		BlockQuote: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-			Indent:      uintPtr(1),
-			IndentToken: stringPtr("│ "),
-		},
-		List: ansi.StyleList{
-			LevelIndent: defaultListIndent,
-		},
-		Heading: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				BlockSuffix:     "\n",
-				Bold:            boolPtr(true),
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		H1: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix:          " ",
-				Suffix:          " ",
-				Bold:            boolPtr(true),
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		H2: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix:          "## ",
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		H3: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix:          "### ",
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		H4: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix:          "#### ",
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		H5: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix:          "##### ",
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		H6: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix:          "###### ",
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		Strikethrough: ansi.StylePrimitive{
-			CrossedOut:      boolPtr(true),
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		Emph: ansi.StylePrimitive{
-			Italic:          boolPtr(true),
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		Strong: ansi.StylePrimitive{
-			Bold:            boolPtr(true),
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		HorizontalRule: ansi.StylePrimitive{
-			Format:          "\n--------\n",
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		Item: ansi.StylePrimitive{
-			BlockPrefix:     "• ",
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		Enumeration: ansi.StylePrimitive{
-			BlockPrefix:     ". ",
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		Task: ansi.StyleTask{
-			StylePrimitive: ansi.StylePrimitive{
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-			Ticked:   "[✓] ",
-			Unticked: "[ ] ",
-		},
-		Link: ansi.StylePrimitive{
-			Underline:       boolPtr(true),
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		LinkText: ansi.StylePrimitive{
-			Bold:            boolPtr(true),
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		Image: ansi.StylePrimitive{
-			Underline:       boolPtr(true),
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		ImageText: ansi.StylePrimitive{
-			Format:          "Image: {{.text}} →",
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-		Code: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix:          " ",
-				Suffix:          " ",
-				Color:           fgColor,
-				BackgroundColor: bgColor,
-			},
-		},
-		CodeBlock: ansi.StyleCodeBlock{
-			StyleBlock: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Color:           fgColor,
-					BackgroundColor: bgColor,
-				},
-				Margin: uintPtr(defaultMargin),
-			},
-		},
-		Table: ansi.StyleTable{
-			StyleBlock: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Color:           fgColor,
-					BackgroundColor: bgColor,
-				},
-			},
-		},
-		DefinitionDescription: ansi.StylePrimitive{
-			BlockPrefix:     "\n ",
-			Color:           fgColor,
-			BackgroundColor: bgColor,
-		},
-	}
-}

internal/tui/styles/theme.go 🔗

@@ -1,709 +0,0 @@
-package styles
-
-import (
-	"fmt"
-	"image/color"
-	"strings"
-	"sync"
-
-	"charm.land/bubbles/v2/filepicker"
-	"charm.land/bubbles/v2/help"
-	"charm.land/bubbles/v2/textarea"
-	"charm.land/bubbles/v2/textinput"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/glamour/v2/ansi"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
-	"github.com/charmbracelet/x/exp/charmtone"
-	"github.com/lucasb-eyer/go-colorful"
-	"github.com/rivo/uniseg"
-)
-
-const (
-	defaultListIndent      = 2
-	defaultListLevelIndent = 4
-	defaultMargin          = 2
-)
-
-type Theme struct {
-	Name   string
-	IsDark bool
-
-	Primary   color.Color
-	Secondary color.Color
-	Tertiary  color.Color
-	Accent    color.Color
-
-	BgBase        color.Color
-	BgBaseLighter color.Color
-	BgSubtle      color.Color
-	BgOverlay     color.Color
-
-	FgBase      color.Color
-	FgMuted     color.Color
-	FgHalfMuted color.Color
-	FgSubtle    color.Color
-	FgSelected  color.Color
-
-	Border      color.Color
-	BorderFocus color.Color
-
-	Success color.Color
-	Error   color.Color
-	Warning color.Color
-	Info    color.Color
-
-	// Colors
-	// White
-	White color.Color
-
-	// Blues
-	BlueLight color.Color
-	BlueDark  color.Color
-	Blue      color.Color
-
-	// Yellows
-	Yellow color.Color
-	Citron color.Color
-
-	// Greens
-	Green      color.Color
-	GreenDark  color.Color
-	GreenLight color.Color
-
-	// Reds
-	Red      color.Color
-	RedDark  color.Color
-	RedLight color.Color
-	Cherry   color.Color
-
-	// Text selection.
-	TextSelection lipgloss.Style
-
-	// LSP and MCP status indicators.
-	ItemOfflineIcon lipgloss.Style
-	ItemBusyIcon    lipgloss.Style
-	ItemErrorIcon   lipgloss.Style
-	ItemOnlineIcon  lipgloss.Style
-
-	// Editor: Yolo Mode.
-	YoloIconFocused lipgloss.Style
-	YoloIconBlurred lipgloss.Style
-	YoloDotsFocused lipgloss.Style
-	YoloDotsBlurred lipgloss.Style
-
-	// oAuth Chooser.
-	AuthBorderSelected   lipgloss.Style
-	AuthTextSelected     lipgloss.Style
-	AuthBorderUnselected lipgloss.Style
-	AuthTextUnselected   lipgloss.Style
-
-	styles     *Styles
-	stylesOnce sync.Once
-}
-
-type Styles struct {
-	Base         lipgloss.Style
-	SelectedBase lipgloss.Style
-
-	Title        lipgloss.Style
-	Subtitle     lipgloss.Style
-	Text         lipgloss.Style
-	TextSelected lipgloss.Style
-	Muted        lipgloss.Style
-	Subtle       lipgloss.Style
-
-	Success lipgloss.Style
-	Error   lipgloss.Style
-	Warning lipgloss.Style
-	Info    lipgloss.Style
-
-	// Markdown & Chroma
-	Markdown ansi.StyleConfig
-
-	// Inputs
-	TextInput textinput.Styles
-	TextArea  textarea.Styles
-
-	// Help
-	Help help.Styles
-
-	// Diff
-	Diff diffview.Style
-
-	// FilePicker
-	FilePicker filepicker.Styles
-}
-
-func (t *Theme) S() *Styles {
-	t.stylesOnce.Do(func() {
-		t.styles = t.buildStyles()
-	})
-	return t.styles
-}
-
-func (t *Theme) buildStyles() *Styles {
-	base := lipgloss.NewStyle().
-		Foreground(t.FgBase)
-	return &Styles{
-		Base: base,
-
-		SelectedBase: base.Background(t.Primary),
-
-		Title: base.
-			Foreground(t.Accent).
-			Bold(true),
-
-		Subtitle: base.
-			Foreground(t.Secondary).
-			Bold(true),
-
-		Text:         base,
-		TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
-
-		Muted: base.Foreground(t.FgMuted),
-
-		Subtle: base.Foreground(t.FgSubtle),
-
-		Success: base.Foreground(t.Success),
-
-		Error: base.Foreground(t.Error),
-
-		Warning: base.Foreground(t.Warning),
-
-		Info: base.Foreground(t.Info),
-
-		TextInput: textinput.Styles{
-			Focused: textinput.StyleState{
-				Text:        base,
-				Placeholder: base.Foreground(t.FgSubtle),
-				Prompt:      base.Foreground(t.Tertiary),
-				Suggestion:  base.Foreground(t.FgSubtle),
-			},
-			Blurred: textinput.StyleState{
-				Text:        base.Foreground(t.FgMuted),
-				Placeholder: base.Foreground(t.FgSubtle),
-				Prompt:      base.Foreground(t.FgMuted),
-				Suggestion:  base.Foreground(t.FgSubtle),
-			},
-			Cursor: textinput.CursorStyle{
-				Color: t.Secondary,
-				Shape: tea.CursorBlock,
-				Blink: true,
-			},
-		},
-		TextArea: textarea.Styles{
-			Focused: textarea.StyleState{
-				Base:             base,
-				Text:             base,
-				LineNumber:       base.Foreground(t.FgSubtle),
-				CursorLine:       base,
-				CursorLineNumber: base.Foreground(t.FgSubtle),
-				Placeholder:      base.Foreground(t.FgSubtle),
-				Prompt:           base.Foreground(t.Tertiary),
-			},
-			Blurred: textarea.StyleState{
-				Base:             base,
-				Text:             base.Foreground(t.FgMuted),
-				LineNumber:       base.Foreground(t.FgMuted),
-				CursorLine:       base,
-				CursorLineNumber: base.Foreground(t.FgMuted),
-				Placeholder:      base.Foreground(t.FgSubtle),
-				Prompt:           base.Foreground(t.FgMuted),
-			},
-			Cursor: textarea.CursorStyle{
-				Color: t.Secondary,
-				Shape: tea.CursorBlock,
-				Blink: true,
-			},
-		},
-
-		Markdown: ansi.StyleConfig{
-			Document: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					// BlockPrefix: "\n",
-					// BlockSuffix: "\n",
-					Color: stringPtr(charmtone.Smoke.Hex()),
-				},
-				// Margin: uintPtr(defaultMargin),
-			},
-			BlockQuote: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{},
-				Indent:         uintPtr(1),
-				IndentToken:    stringPtr("│ "),
-			},
-			List: ansi.StyleList{
-				LevelIndent: defaultListIndent,
-			},
-			Heading: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					BlockSuffix: "\n",
-					Color:       stringPtr(charmtone.Malibu.Hex()),
-					Bold:        boolPtr(true),
-				},
-			},
-			H1: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Prefix:          " ",
-					Suffix:          " ",
-					Color:           stringPtr(charmtone.Zest.Hex()),
-					BackgroundColor: stringPtr(charmtone.Charple.Hex()),
-					Bold:            boolPtr(true),
-				},
-			},
-			H2: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Prefix: "## ",
-				},
-			},
-			H3: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Prefix: "### ",
-				},
-			},
-			H4: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Prefix: "#### ",
-				},
-			},
-			H5: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Prefix: "##### ",
-				},
-			},
-			H6: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Prefix: "###### ",
-					Color:  stringPtr(charmtone.Guac.Hex()),
-					Bold:   boolPtr(false),
-				},
-			},
-			Strikethrough: ansi.StylePrimitive{
-				CrossedOut: boolPtr(true),
-			},
-			Emph: ansi.StylePrimitive{
-				Italic: boolPtr(true),
-			},
-			Strong: ansi.StylePrimitive{
-				Bold: boolPtr(true),
-			},
-			HorizontalRule: ansi.StylePrimitive{
-				Color:  stringPtr(charmtone.Charcoal.Hex()),
-				Format: "\n--------\n",
-			},
-			Item: ansi.StylePrimitive{
-				BlockPrefix: "• ",
-			},
-			Enumeration: ansi.StylePrimitive{
-				BlockPrefix: ". ",
-			},
-			Task: ansi.StyleTask{
-				StylePrimitive: ansi.StylePrimitive{},
-				Ticked:         "[✓] ",
-				Unticked:       "[ ] ",
-			},
-			Link: ansi.StylePrimitive{
-				Color:     stringPtr(charmtone.Zinc.Hex()),
-				Underline: boolPtr(true),
-			},
-			LinkText: ansi.StylePrimitive{
-				Color: stringPtr(charmtone.Guac.Hex()),
-				Bold:  boolPtr(true),
-			},
-			Image: ansi.StylePrimitive{
-				Color:     stringPtr(charmtone.Cheeky.Hex()),
-				Underline: boolPtr(true),
-			},
-			ImageText: ansi.StylePrimitive{
-				Color:  stringPtr(charmtone.Squid.Hex()),
-				Format: "Image: {{.text}} →",
-			},
-			Code: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Prefix:          " ",
-					Suffix:          " ",
-					Color:           stringPtr(charmtone.Coral.Hex()),
-					BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
-				},
-			},
-			CodeBlock: ansi.StyleCodeBlock{
-				StyleBlock: ansi.StyleBlock{
-					StylePrimitive: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Charcoal.Hex()),
-					},
-					Margin: uintPtr(defaultMargin),
-				},
-				Chroma: &ansi.Chroma{
-					Text: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Smoke.Hex()),
-					},
-					Error: ansi.StylePrimitive{
-						Color:           stringPtr(charmtone.Butter.Hex()),
-						BackgroundColor: stringPtr(charmtone.Sriracha.Hex()),
-					},
-					Comment: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Oyster.Hex()),
-					},
-					CommentPreproc: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Bengal.Hex()),
-					},
-					Keyword: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Malibu.Hex()),
-					},
-					KeywordReserved: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Pony.Hex()),
-					},
-					KeywordNamespace: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Pony.Hex()),
-					},
-					KeywordType: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Guppy.Hex()),
-					},
-					Operator: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Salmon.Hex()),
-					},
-					Punctuation: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Zest.Hex()),
-					},
-					Name: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Smoke.Hex()),
-					},
-					NameBuiltin: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Cheeky.Hex()),
-					},
-					NameTag: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Mauve.Hex()),
-					},
-					NameAttribute: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Hazy.Hex()),
-					},
-					NameClass: ansi.StylePrimitive{
-						Color:     stringPtr(charmtone.Salt.Hex()),
-						Underline: boolPtr(true),
-						Bold:      boolPtr(true),
-					},
-					NameDecorator: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Citron.Hex()),
-					},
-					NameFunction: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Guac.Hex()),
-					},
-					LiteralNumber: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Julep.Hex()),
-					},
-					LiteralString: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Cumin.Hex()),
-					},
-					LiteralStringEscape: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Bok.Hex()),
-					},
-					GenericDeleted: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Coral.Hex()),
-					},
-					GenericEmph: ansi.StylePrimitive{
-						Italic: boolPtr(true),
-					},
-					GenericInserted: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Guac.Hex()),
-					},
-					GenericStrong: ansi.StylePrimitive{
-						Bold: boolPtr(true),
-					},
-					GenericSubheading: ansi.StylePrimitive{
-						Color: stringPtr(charmtone.Squid.Hex()),
-					},
-					Background: ansi.StylePrimitive{
-						BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
-					},
-				},
-			},
-			Table: ansi.StyleTable{
-				StyleBlock: ansi.StyleBlock{
-					StylePrimitive: ansi.StylePrimitive{},
-				},
-			},
-			DefinitionDescription: ansi.StylePrimitive{
-				BlockPrefix: "\n ",
-			},
-		},
-
-		Help: help.Styles{
-			ShortKey:       base.Foreground(t.FgMuted),
-			ShortDesc:      base.Foreground(t.FgSubtle),
-			ShortSeparator: base.Foreground(t.Border),
-			Ellipsis:       base.Foreground(t.Border),
-			FullKey:        base.Foreground(t.FgMuted),
-			FullDesc:       base.Foreground(t.FgSubtle),
-			FullSeparator:  base.Foreground(t.Border),
-		},
-
-		Diff: diffview.Style{
-			DividerLine: diffview.LineStyle{
-				LineNumber: lipgloss.NewStyle().
-					Foreground(t.FgHalfMuted).
-					Background(t.BgBaseLighter),
-				Code: lipgloss.NewStyle().
-					Foreground(t.FgHalfMuted).
-					Background(t.BgBaseLighter),
-			},
-			MissingLine: diffview.LineStyle{
-				LineNumber: lipgloss.NewStyle().
-					Background(t.BgBaseLighter),
-				Code: lipgloss.NewStyle().
-					Background(t.BgBaseLighter),
-			},
-			EqualLine: diffview.LineStyle{
-				LineNumber: lipgloss.NewStyle().
-					Foreground(t.FgMuted).
-					Background(t.BgBase),
-				Code: lipgloss.NewStyle().
-					Foreground(t.FgMuted).
-					Background(t.BgBase),
-			},
-			InsertLine: diffview.LineStyle{
-				LineNumber: lipgloss.NewStyle().
-					Foreground(lipgloss.Color("#629657")).
-					Background(lipgloss.Color("#2b322a")),
-				Symbol: lipgloss.NewStyle().
-					Foreground(lipgloss.Color("#629657")).
-					Background(lipgloss.Color("#323931")),
-				Code: lipgloss.NewStyle().
-					Background(lipgloss.Color("#323931")),
-			},
-			DeleteLine: diffview.LineStyle{
-				LineNumber: lipgloss.NewStyle().
-					Foreground(lipgloss.Color("#a45c59")).
-					Background(lipgloss.Color("#312929")),
-				Symbol: lipgloss.NewStyle().
-					Foreground(lipgloss.Color("#a45c59")).
-					Background(lipgloss.Color("#383030")),
-				Code: lipgloss.NewStyle().
-					Background(lipgloss.Color("#383030")),
-			},
-		},
-		FilePicker: filepicker.Styles{
-			DisabledCursor:   base.Foreground(t.FgMuted),
-			Cursor:           base.Foreground(t.FgBase),
-			Symlink:          base.Foreground(t.FgSubtle),
-			Directory:        base.Foreground(t.Primary),
-			File:             base.Foreground(t.FgBase),
-			DisabledFile:     base.Foreground(t.FgMuted),
-			DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted),
-			Permission:       base.Foreground(t.FgMuted),
-			Selected:         base.Background(t.Primary).Foreground(t.FgBase),
-			FileSize:         base.Foreground(t.FgMuted),
-			EmptyDirectory:   base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"),
-		},
-	}
-}
-
-type Manager struct {
-	themes  map[string]*Theme
-	current *Theme
-}
-
-var (
-	defaultManager     *Manager
-	defaultManagerOnce sync.Once
-)
-
-func initDefaultManager() *Manager {
-	defaultManagerOnce.Do(func() {
-		defaultManager = newManager()
-	})
-	return defaultManager
-}
-
-func SetDefaultManager(m *Manager) {
-	defaultManager = m
-}
-
-func DefaultManager() *Manager {
-	return initDefaultManager()
-}
-
-func CurrentTheme() *Theme {
-	return initDefaultManager().Current()
-}
-
-func newManager() *Manager {
-	m := &Manager{
-		themes: make(map[string]*Theme),
-	}
-
-	t := NewCharmtoneTheme() // default theme
-	m.Register(t)
-	m.current = m.themes[t.Name]
-
-	return m
-}
-
-func (m *Manager) Register(theme *Theme) {
-	m.themes[theme.Name] = theme
-}
-
-func (m *Manager) Current() *Theme {
-	return m.current
-}
-
-func (m *Manager) SetTheme(name string) error {
-	if theme, ok := m.themes[name]; ok {
-		m.current = theme
-		return nil
-	}
-	return fmt.Errorf("theme %s not found", name)
-}
-
-func (m *Manager) List() []string {
-	names := make([]string, 0, len(m.themes))
-	for name := range m.themes {
-		names = append(names, name)
-	}
-	return names
-}
-
-// ParseHex converts hex string to color
-func ParseHex(hex string) color.Color {
-	var r, g, b uint8
-	fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
-	return color.RGBA{R: r, G: g, B: b, A: 255}
-}
-
-// Alpha returns a color with transparency
-func Alpha(c color.Color, alpha uint8) color.Color {
-	r, g, b, _ := c.RGBA()
-	return color.RGBA{
-		R: uint8(r >> 8),
-		G: uint8(g >> 8),
-		B: uint8(b >> 8),
-		A: alpha,
-	}
-}
-
-// Darken makes a color darker by percentage (0-100)
-func Darken(c color.Color, percent float64) color.Color {
-	r, g, b, a := c.RGBA()
-	factor := 1.0 - percent/100.0
-	return color.RGBA{
-		R: uint8(float64(r>>8) * factor),
-		G: uint8(float64(g>>8) * factor),
-		B: uint8(float64(b>>8) * factor),
-		A: uint8(a >> 8),
-	}
-}
-
-// Lighten makes a color lighter by percentage (0-100)
-func Lighten(c color.Color, percent float64) color.Color {
-	r, g, b, a := c.RGBA()
-	factor := percent / 100.0
-	return color.RGBA{
-		R: uint8(min(255, float64(r>>8)+255*factor)),
-		G: uint8(min(255, float64(g>>8)+255*factor)),
-		B: uint8(min(255, float64(b>>8)+255*factor)),
-		A: uint8(a >> 8),
-	}
-}
-
-func ForegroundGrad(input string, bold bool, color1, color2 color.Color) []string {
-	if input == "" {
-		return []string{""}
-	}
-	t := CurrentTheme()
-	if len(input) == 1 {
-		style := t.S().Base.Foreground(color1)
-		if bold {
-			style.Bold(true)
-		}
-		return []string{style.Render(input)}
-	}
-	var clusters []string
-	gr := uniseg.NewGraphemes(input)
-	for gr.Next() {
-		clusters = append(clusters, string(gr.Runes()))
-	}
-
-	ramp := blendColors(len(clusters), color1, color2)
-	for i, c := range ramp {
-		style := t.S().Base.Foreground(c)
-		if bold {
-			style.Bold(true)
-		}
-		clusters[i] = style.Render(clusters[i])
-	}
-	return clusters
-}
-
-// ApplyForegroundGrad renders a given string with a horizontal gradient
-// foreground.
-func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
-	if input == "" {
-		return ""
-	}
-	var o strings.Builder
-	clusters := ForegroundGrad(input, false, color1, color2)
-	for _, c := range clusters {
-		fmt.Fprint(&o, c)
-	}
-	return o.String()
-}
-
-// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
-// foreground.
-func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string {
-	if input == "" {
-		return ""
-	}
-	var o strings.Builder
-	clusters := ForegroundGrad(input, true, color1, color2)
-	for _, c := range clusters {
-		fmt.Fprint(&o, c)
-	}
-	return o.String()
-}
-
-// blendColors returns a slice of colors blended between the given keys.
-// Blending is done in Hcl to stay in gamut.
-func blendColors(size int, stops ...color.Color) []color.Color {
-	if len(stops) < 2 {
-		return nil
-	}
-
-	stopsPrime := make([]colorful.Color, len(stops))
-	for i, k := range stops {
-		stopsPrime[i], _ = colorful.MakeColor(k)
-	}
-
-	numSegments := len(stopsPrime) - 1
-	blended := make([]color.Color, 0, size)
-
-	// Calculate how many colors each segment should have.
-	segmentSizes := make([]int, numSegments)
-	baseSize := size / numSegments
-	remainder := size % numSegments
-
-	// Distribute the remainder across segments.
-	for i := range numSegments {
-		segmentSizes[i] = baseSize
-		if i < remainder {
-			segmentSizes[i]++
-		}
-	}
-
-	// Generate colors for each segment.
-	for i := range numSegments {
-		c1 := stopsPrime[i]
-		c2 := stopsPrime[i+1]
-		segmentSize := segmentSizes[i]
-
-		for j := range segmentSize {
-			var t float64
-			if segmentSize > 1 {
-				t = float64(j) / float64(segmentSize-1)
-			}
-			c := c1.BlendHcl(c2, t)
-			blended = append(blended, c)
-		}
-	}
-
-	return blended
-}

internal/tui/tui.go 🔗

@@ -1,712 +0,0 @@
-package tui
-
-import (
-	"context"
-	"fmt"
-	"math/rand"
-	"regexp"
-	"slices"
-	"strings"
-	"time"
-
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
-	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
-	"github.com/charmbracelet/crush/internal/tui/components/completions"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/core/status"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
-	"github.com/charmbracelet/crush/internal/tui/page"
-	"github.com/charmbracelet/crush/internal/tui/page/chat"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	xstrings "github.com/charmbracelet/x/exp/strings"
-	"golang.org/x/mod/semver"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-)
-
-var lastMouseEvent time.Time
-
-func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
-	switch msg.(type) {
-	case tea.MouseWheelMsg, tea.MouseMotionMsg:
-		now := time.Now()
-		// trackpad is sending too many requests
-		if now.Sub(lastMouseEvent) < 15*time.Millisecond {
-			return nil
-		}
-		lastMouseEvent = now
-	}
-	return msg
-}
-
-// appModel represents the main application model that manages pages, dialogs, and UI state.
-type appModel struct {
-	wWidth, wHeight int // Window dimensions
-	width, height   int
-	keyMap          KeyMap
-
-	currentPage  page.PageID
-	previousPage page.PageID
-	pages        map[page.PageID]util.Model
-	loadedPages  map[page.PageID]bool
-
-	// Status
-	status          status.StatusCmp
-	showingFullHelp bool
-
-	app *app.App
-
-	dialog       dialogs.DialogCmp
-	completions  completions.Completions
-	isConfigured bool
-
-	// Chat Page Specific
-	selectedSessionID string // The ID of the currently selected session
-
-	// sendProgressBar instructs the TUI to send progress bar updates to the
-	// terminal.
-	sendProgressBar bool
-
-	// QueryVersion instructs the TUI to query for the terminal version when it
-	// starts.
-	QueryVersion bool
-}
-
-// Init initializes the application model and returns initial commands.
-func (a appModel) Init() tea.Cmd {
-	item, ok := a.pages[a.currentPage]
-	if !ok {
-		return nil
-	}
-
-	var cmds []tea.Cmd
-	cmd := item.Init()
-	cmds = append(cmds, cmd)
-	a.loadedPages[a.currentPage] = true
-
-	cmd = a.status.Init()
-	cmds = append(cmds, cmd)
-	if a.QueryVersion {
-		cmds = append(cmds, tea.RequestTerminalVersion)
-	}
-
-	return tea.Batch(cmds...)
-}
-
-// Update handles incoming messages and updates the application state.
-func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	var cmd tea.Cmd
-	a.isConfigured = config.HasInitialDataConfig()
-
-	switch msg := msg.(type) {
-	case tea.EnvMsg:
-		// Is this Windows Terminal?
-		if !a.sendProgressBar {
-			a.sendProgressBar = slices.Contains(msg, "WT_SESSION")
-		}
-	case tea.TerminalVersionMsg:
-		if a.sendProgressBar {
-			return a, nil
-		}
-		termVersion := strings.ToLower(msg.Name)
-		switch {
-		case xstrings.ContainsAnyOf(termVersion, "ghostty", "rio"):
-			a.sendProgressBar = true
-		case strings.Contains(termVersion, "iterm2"):
-			// iTerm2 supports progress bars from version v3.6.6
-			matches := regexp.MustCompile(`^iterm2 (\d+\.\d+\.\d+)$`).FindStringSubmatch(termVersion)
-			if len(matches) == 2 && semver.Compare("v"+matches[1], "v3.6.6") >= 0 {
-				a.sendProgressBar = true
-			}
-		}
-		return a, nil
-	case tea.KeyboardEnhancementsMsg:
-		// A non-zero value means we have key disambiguation support.
-		if msg.Flags > 0 {
-			a.keyMap.Models.SetHelp("ctrl+m", "models")
-		}
-		for id, page := range a.pages {
-			m, pageCmd := page.Update(msg)
-			a.pages[id] = m
-
-			if pageCmd != nil {
-				cmds = append(cmds, pageCmd)
-			}
-		}
-		return a, tea.Batch(cmds...)
-	case tea.WindowSizeMsg:
-		a.wWidth, a.wHeight = msg.Width, msg.Height
-		a.completions.Update(msg)
-		return a, a.handleWindowResize(msg.Width, msg.Height)
-
-	case pubsub.Event[mcp.Event]:
-		switch msg.Payload.Type {
-		case mcp.EventStateChanged:
-			return a, a.handleStateChanged(context.Background())
-		case mcp.EventPromptsListChanged:
-			return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
-		case mcp.EventToolsListChanged:
-			return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name)
-		}
-
-	// Completions messages
-	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
-		completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
-		u, completionCmd := a.completions.Update(msg)
-		if model, ok := u.(completions.Completions); ok {
-			a.completions = model
-		}
-
-		return a, completionCmd
-
-	// Dialog messages
-	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
-		u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
-		a.completions = u.(completions.Completions)
-		u, dialogCmd := a.dialog.Update(msg)
-		a.dialog = u.(dialogs.DialogCmp)
-		return a, tea.Batch(completionCmd, dialogCmd)
-	case commands.ShowArgumentsDialogMsg:
-		var args []commands.Argument
-		for _, arg := range msg.ArgNames {
-			args = append(args, commands.Argument{
-				Name:     arg,
-				Title:    cases.Title(language.English).String(arg),
-				Required: true,
-			})
-		}
-		return a, util.CmdHandler(
-			dialogs.OpenDialogMsg{
-				Model: commands.NewCommandArgumentsDialog(
-					msg.CommandID,
-					msg.CommandID,
-					msg.CommandID,
-					msg.Description,
-					args,
-					msg.OnSubmit,
-				),
-			},
-		)
-	case commands.ShowMCPPromptArgumentsDialogMsg:
-		args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
-		for _, arg := range msg.Prompt.Arguments {
-			args = append(args, commands.Argument(*arg))
-		}
-		dialog := commands.NewCommandArgumentsDialog(
-			msg.Prompt.Name,
-			msg.Prompt.Title,
-			msg.Prompt.Name,
-			msg.Prompt.Description,
-			args,
-			msg.OnSubmit,
-		)
-		return a, util.CmdHandler(
-			dialogs.OpenDialogMsg{
-				Model: dialog,
-			},
-		)
-	// Page change messages
-	case page.PageChangeMsg:
-		return a, a.moveToPage(msg.ID)
-
-	// Status Messages
-	case util.InfoMsg, util.ClearStatusMsg:
-		s, statusCmd := a.status.Update(msg)
-		a.status = s.(status.StatusCmp)
-		cmds = append(cmds, statusCmd)
-		return a, tea.Batch(cmds...)
-
-	// Session
-	case cmpChat.SessionSelectedMsg:
-		a.selectedSessionID = msg.ID
-	case cmpChat.SessionClearedMsg:
-		a.selectedSessionID = ""
-	// Commands
-	case commands.SwitchSessionsMsg:
-		return a, func() tea.Msg {
-			allSessions, _ := a.app.Sessions.List(context.Background())
-			return dialogs.OpenDialogMsg{
-				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
-			}
-		}
-
-	case commands.SwitchModelMsg:
-		return a, util.CmdHandler(
-			dialogs.OpenDialogMsg{
-				Model: models.NewModelDialogCmp(),
-			},
-		)
-	// Compact
-	case commands.CompactMsg:
-		return a, func() tea.Msg {
-			err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
-			if err != nil {
-				return util.ReportError(err)()
-			}
-			return nil
-		}
-	case commands.QuitMsg:
-		return a, util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: quit.NewQuitDialog(),
-		})
-	case commands.ToggleYoloModeMsg:
-		a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
-	case commands.ToggleHelpMsg:
-		a.status.ToggleFullHelp()
-		a.showingFullHelp = !a.showingFullHelp
-		return a, a.handleWindowResize(a.wWidth, a.wHeight)
-	// Model Switch
-	case models.ModelSelectedMsg:
-		if a.app.AgentCoordinator.IsBusy() {
-			return a, util.ReportWarn("Agent is busy, please wait...")
-		}
-
-		cfg := config.Get()
-		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
-			return a, util.ReportError(err)
-		}
-
-		go a.app.UpdateAgentModel(context.TODO())
-
-		modelTypeName := "large"
-		if msg.ModelType == config.SelectedModelTypeSmall {
-			modelTypeName = "small"
-		}
-		return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
-
-	// File Picker
-	case commands.OpenFilePickerMsg:
-		event.FilePickerOpened()
-
-		if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
-			// If the commands dialog is already open, close it
-			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
-		}
-		return a, util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
-		})
-	// Permissions
-	case pubsub.Event[permission.PermissionNotification]:
-		item, ok := a.pages[a.currentPage]
-		if !ok {
-			return a, nil
-		}
-
-		// Forward to view.
-		updated, itemCmd := item.Update(msg)
-		a.pages[a.currentPage] = updated
-
-		return a, itemCmd
-	case pubsub.Event[permission.PermissionRequest]:
-		return a, util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
-				DiffMode: config.Get().Options.TUI.DiffMode,
-			}),
-		})
-	case permissions.PermissionResponseMsg:
-		switch msg.Action {
-		case permissions.PermissionAllow:
-			a.app.Permissions.Grant(msg.Permission)
-		case permissions.PermissionAllowForSession:
-			a.app.Permissions.GrantPersistent(msg.Permission)
-		case permissions.PermissionDeny:
-			a.app.Permissions.Deny(msg.Permission)
-		}
-		return a, nil
-	case splash.OnboardingCompleteMsg:
-		item, ok := a.pages[a.currentPage]
-		if !ok {
-			return a, nil
-		}
-
-		a.isConfigured = config.HasInitialDataConfig()
-		updated, pageCmd := item.Update(msg)
-		a.pages[a.currentPage] = updated
-
-		cmds = append(cmds, pageCmd)
-		return a, tea.Batch(cmds...)
-
-	case tea.KeyPressMsg:
-		return a, a.handleKeyPressMsg(msg)
-
-	case tea.MouseWheelMsg:
-		if a.dialog.HasDialogs() {
-			u, dialogCmd := a.dialog.Update(msg)
-			a.dialog = u.(dialogs.DialogCmp)
-			cmds = append(cmds, dialogCmd)
-		} else {
-			item, ok := a.pages[a.currentPage]
-			if !ok {
-				return a, nil
-			}
-
-			updated, pageCmd := item.Update(msg)
-			a.pages[a.currentPage] = updated
-
-			cmds = append(cmds, pageCmd)
-		}
-		return a, tea.Batch(cmds...)
-	case tea.PasteMsg:
-		if a.dialog.HasDialogs() {
-			u, dialogCmd := a.dialog.Update(msg)
-			if model, ok := u.(dialogs.DialogCmp); ok {
-				a.dialog = model
-			}
-
-			cmds = append(cmds, dialogCmd)
-		} else {
-			item, ok := a.pages[a.currentPage]
-			if !ok {
-				return a, nil
-			}
-
-			updated, pageCmd := item.Update(msg)
-			a.pages[a.currentPage] = updated
-
-			cmds = append(cmds, pageCmd)
-		}
-		return a, tea.Batch(cmds...)
-	// Update Available
-	case app.UpdateAvailableMsg:
-		// Show update notification in status bar
-		statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
-		if msg.IsDevelopment {
-			statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion)
-		}
-		s, statusCmd := a.status.Update(util.InfoMsg{
-			Type: util.InfoTypeUpdate,
-			Msg:  statusMsg,
-			TTL:  10 * time.Second,
-		})
-		a.status = s.(status.StatusCmp)
-		return a, statusCmd
-	}
-	s, _ := a.status.Update(msg)
-	a.status = s.(status.StatusCmp)
-
-	item, ok := a.pages[a.currentPage]
-	if !ok {
-		return a, nil
-	}
-
-	updated, cmd := item.Update(msg)
-	a.pages[a.currentPage] = updated
-
-	if a.dialog.HasDialogs() {
-		u, dialogCmd := a.dialog.Update(msg)
-		if model, ok := u.(dialogs.DialogCmp); ok {
-			a.dialog = model
-		}
-
-		cmds = append(cmds, dialogCmd)
-	}
-	cmds = append(cmds, cmd)
-	return a, tea.Batch(cmds...)
-}
-
-// handleWindowResize processes window resize events and updates all components.
-func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
-	var cmds []tea.Cmd
-
-	// TODO: clean up these magic numbers.
-	if a.showingFullHelp {
-		height -= 5
-	} else {
-		height -= 2
-	}
-
-	a.width, a.height = width, height
-	// Update status bar
-	s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
-	if model, ok := s.(status.StatusCmp); ok {
-		a.status = model
-	}
-	cmds = append(cmds, cmd)
-
-	// Update the current view.
-	for p, page := range a.pages {
-		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
-		a.pages[p] = updated
-
-		cmds = append(cmds, pageCmd)
-	}
-
-	// Update the dialogs
-	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
-	if model, ok := dialog.(dialogs.DialogCmp); ok {
-		a.dialog = model
-	}
-
-	cmds = append(cmds, cmd)
-
-	return tea.Batch(cmds...)
-}
-
-// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
-func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
-	// Check this first as the user should be able to quit no matter what.
-	if key.Matches(msg, a.keyMap.Quit) {
-		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
-			return tea.Quit
-		}
-		return util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: quit.NewQuitDialog(),
-		})
-	}
-
-	if a.completions.Open() {
-		// completions
-		keyMap := a.completions.KeyMap()
-		switch {
-		case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
-			key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
-			key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
-			u, cmd := a.completions.Update(msg)
-			a.completions = u.(completions.Completions)
-			return cmd
-		}
-	}
-	if a.dialog.HasDialogs() {
-		u, dialogCmd := a.dialog.Update(msg)
-		a.dialog = u.(dialogs.DialogCmp)
-		return dialogCmd
-	}
-	switch {
-	// help
-	case key.Matches(msg, a.keyMap.Help):
-		a.status.ToggleFullHelp()
-		a.showingFullHelp = !a.showingFullHelp
-		return a.handleWindowResize(a.wWidth, a.wHeight)
-	// dialogs
-	case key.Matches(msg, a.keyMap.Commands):
-		// if the app is not configured show no commands
-		if !a.isConfigured {
-			return nil
-		}
-		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
-			return util.CmdHandler(dialogs.CloseDialogMsg{})
-		}
-		if a.dialog.HasDialogs() {
-			return nil
-		}
-		return util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: commands.NewCommandDialog(a.selectedSessionID),
-		})
-	case key.Matches(msg, a.keyMap.Models):
-		// if the app is not configured show no models
-		if !a.isConfigured {
-			return nil
-		}
-		if a.dialog.ActiveDialogID() == models.ModelsDialogID {
-			return util.CmdHandler(dialogs.CloseDialogMsg{})
-		}
-		if a.dialog.HasDialogs() {
-			return nil
-		}
-		return util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: models.NewModelDialogCmp(),
-		})
-	case key.Matches(msg, a.keyMap.Sessions):
-		// if the app is not configured show no sessions
-		if !a.isConfigured {
-			return nil
-		}
-		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
-			return util.CmdHandler(dialogs.CloseDialogMsg{})
-		}
-		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
-			return nil
-		}
-		var cmds []tea.Cmd
-		cmds = append(cmds,
-			func() tea.Msg {
-				allSessions, _ := a.app.Sessions.List(context.Background())
-				return dialogs.OpenDialogMsg{
-					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
-				}
-			},
-		)
-		return tea.Sequence(cmds...)
-	case key.Matches(msg, a.keyMap.Suspend):
-		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
-			return util.ReportWarn("Agent is busy, please wait...")
-		}
-		return tea.Suspend
-	default:
-		item, ok := a.pages[a.currentPage]
-		if !ok {
-			return nil
-		}
-
-		updated, cmd := item.Update(msg)
-		a.pages[a.currentPage] = updated
-		return cmd
-	}
-}
-
-// moveToPage handles navigation between different pages in the application.
-func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
-	if a.app.AgentCoordinator.IsBusy() {
-		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
-		return util.ReportWarn("Agent is busy, please wait...")
-	}
-
-	var cmds []tea.Cmd
-	if _, ok := a.loadedPages[pageID]; !ok {
-		cmd := a.pages[pageID].Init()
-		cmds = append(cmds, cmd)
-		a.loadedPages[pageID] = true
-	}
-	a.previousPage = a.currentPage
-	a.currentPage = pageID
-	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
-		cmd := sizable.SetSize(a.width, a.height)
-		cmds = append(cmds, cmd)
-	}
-
-	return tea.Batch(cmds...)
-}
-
-// View renders the complete application interface including pages, dialogs, and overlays.
-func (a *appModel) View() tea.View {
-	var view tea.View
-	t := styles.CurrentTheme()
-	view.AltScreen = true
-	view.MouseMode = tea.MouseModeCellMotion
-	view.BackgroundColor = t.BgBase
-	view.WindowTitle = "crush " + home.Short(config.Get().WorkingDir())
-	if a.wWidth < 25 || a.wHeight < 15 {
-		view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight).
-			Align(lipgloss.Center, lipgloss.Center).
-			Render(t.S().Base.
-				Padding(1, 4).
-				Foreground(t.White).
-				BorderStyle(lipgloss.RoundedBorder()).
-				BorderForeground(t.Primary).
-				Render("Window too small!"),
-			)
-		return view
-	}
-
-	page := a.pages[a.currentPage]
-	if withHelp, ok := page.(core.KeyMapHelp); ok {
-		a.status.SetKeyMap(withHelp.Help())
-	}
-	pageView := page.View()
-	components := []string{
-		pageView,
-	}
-	components = append(components, a.status.View())
-
-	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
-	layers := []*lipgloss.Layer{
-		lipgloss.NewLayer(appView),
-	}
-	if a.dialog.HasDialogs() {
-		layers = append(
-			layers,
-			a.dialog.GetLayers()...,
-		)
-	}
-
-	var cursor *tea.Cursor
-	if v, ok := page.(util.Cursor); ok {
-		cursor = v.Cursor()
-		// Hide the cursor if it's positioned outside the textarea
-		statusHeight := a.height - strings.Count(pageView, "\n") + 1
-		if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
-			cursor = nil
-		}
-	}
-	activeView := a.dialog.ActiveModel()
-	if activeView != nil {
-		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
-		if v, ok := activeView.(util.Cursor); ok {
-			cursor = v.Cursor()
-		}
-	}
-
-	if a.completions.Open() && cursor != nil {
-		cmp := a.completions.View()
-		x, y := a.completions.Position()
-		layers = append(
-			layers,
-			lipgloss.NewLayer(cmp).X(x).Y(y),
-		)
-	}
-
-	comp := lipgloss.NewCompositor(layers...)
-	view.Content = comp.Render()
-	view.Cursor = cursor
-
-	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
-		// HACK: use a random percentage to prevent ghostty from hiding it
-		// after a timeout.
-		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
-	}
-	return view
-}
-
-func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
-	return func() tea.Msg {
-		a.app.UpdateAgentModel(ctx)
-		return nil
-	}
-}
-
-func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
-	return func() tea.Msg {
-		mcp.RefreshPrompts(ctx, name)
-		return nil
-	}
-}
-
-func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
-	return func() tea.Msg {
-		mcp.RefreshTools(ctx, name)
-		return nil
-	}
-}
-
-// New creates and initializes a new TUI application model.
-func New(app *app.App) *appModel {
-	chatPage := chat.New(app)
-	keyMap := DefaultKeyMap()
-	keyMap.pageBindings = chatPage.Bindings()
-
-	model := &appModel{
-		currentPage: chat.ChatPageID,
-		app:         app,
-		status:      status.NewStatusCmp(),
-		loadedPages: make(map[page.PageID]bool),
-		keyMap:      keyMap,
-
-		pages: map[page.PageID]util.Model{
-			chat.ChatPageID: chatPage,
-		},
-
-		dialog:      dialogs.NewDialogCmp(),
-		completions: completions.New(),
-	}
-
-	return model
-}

internal/tui/util/shell.go 🔗

@@ -1,15 +0,0 @@
-package util
-
-import (
-	"context"
-
-	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/uiutil"
-)
-
-// ExecShell parses a shell command string and executes it with exec.Command.
-// Uses shell.Fields for proper handling of shell syntax like quotes and
-// arguments while preserving TTY handling for terminal editors.
-func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd {
-	return uiutil.ExecShell(ctx, cmdStr, callback)
-}

internal/tui/util/util.go 🔗

@@ -1,45 +0,0 @@
-package util
-
-import (
-	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/uiutil"
-)
-
-type Cursor = uiutil.Cursor
-
-type Model interface {
-	Init() tea.Cmd
-	Update(tea.Msg) (Model, tea.Cmd)
-	View() string
-}
-
-func CmdHandler(msg tea.Msg) tea.Cmd {
-	return uiutil.CmdHandler(msg)
-}
-
-func ReportError(err error) tea.Cmd {
-	return uiutil.ReportError(err)
-}
-
-type InfoType = uiutil.InfoType
-
-const (
-	InfoTypeInfo    = uiutil.InfoTypeInfo
-	InfoTypeSuccess = uiutil.InfoTypeSuccess
-	InfoTypeWarn    = uiutil.InfoTypeWarn
-	InfoTypeError   = uiutil.InfoTypeError
-	InfoTypeUpdate  = uiutil.InfoTypeUpdate
-)
-
-func ReportInfo(info string) tea.Cmd {
-	return uiutil.ReportInfo(info)
-}
-
-func ReportWarn(warn string) tea.Cmd {
-	return uiutil.ReportWarn(warn)
-}
-
-type (
-	InfoMsg        = uiutil.InfoMsg
-	ClearStatusMsg = uiutil.ClearStatusMsg
-)

internal/ui/AGENTS.md 🔗

@@ -1,15 +1,25 @@
 # UI Development Instructions
 
 ## General Guidelines
+
 - Never use commands to send messages when you can directly mutate children or state.
 - Keep things simple; do not overcomplicate.
 - Create files if needed to separate logic; do not nest models.
-- Always do IO in commands
+- Never do IO or expensive work in `Update`; always use a `tea.Cmd`.
 - Never change the model state inside of a command use messages and than update the state in the main loop
+- Use the `github.com/charmbracelet/x/ansi` package for any string manipulation
+  that might involves ANSI codes. Do not manipulate ANSI strings at byte level!
+  Some useful functions:
+  * `ansi.Cut`
+  * `ansi.StringWidth`
+  * `ansi.Strip`
+  * `ansi.Truncate`
+
 
 ## Architecture
 
 ### Main Model (`model/ui.go`)
+
 Keep most of the logic and state in the main model. This is where:
 - Message routing happens
 - Focus and UI state is managed
@@ -17,35 +27,42 @@ Keep most of the logic and state in the main model. This is where:
 - Dialogs are orchestrated
 
 ### Components Should Be Dumb
+
 Components should not handle bubbletea messages directly. Instead:
 - Expose methods for state changes
 - Return `tea.Cmd` from methods when side effects are needed
 - Handle their own rendering via `Render(width int) string`
 
 ### Chat Logic (`model/chat.go`)
+
 Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`).
 
 ## Key Patterns
 
 ### Composition Over Inheritance
+
 Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus.
 
 ### Interfaces
+
 - List item interfaces are in `list/item.go`
 - Chat message interfaces are in `chat/messages.go`
 - Dialog interface is in `dialog/dialog.go`
 
 ### Styling
+
 - All styles are defined in `styles/styles.go`
 - Access styles via `*common.Common` passed to components
 - Use semantic color fields rather than hardcoded colors
 
 ### Dialogs
+
 - Implement the dialog interface in `dialog/dialog.go`
 - Return message types from `Update()` to signal actions to the main model
 - Use the overlay system for managing dialog lifecycle
 
 ## File Organization
+
 - `model/` - Main UI model and major components (chat, sidebar, etc.)
 - `chat/` - Chat message item types and renderers
 - `dialog/` - Dialog implementations
@@ -56,6 +73,7 @@ Use struct embedding for shared behaviors. See `chat/messages.go` for examples o
 - `logo/` - Logo rendering
 
 ## Common Gotchas
+
 - Always account for padding/borders in width calculations
 - Use `tea.Batch()` when returning multiple commands
 - Pass `*common.Common` to components that need styles or app access

internal/ui/chat/agent.go 🔗

@@ -242,7 +242,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int
 	prompt = strings.ReplaceAll(prompt, "\n", " ")
 
 	// Build header with optional URL param.
-	toolParams := []string{}
+	var toolParams []string
 	if params.URL != "" {
 		toolParams = append(toolParams, params.URL)
 	}

internal/ui/chat/lsp_restart.go 🔗

@@ -38,7 +38,7 @@ func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int,
 	var params tools.LSPRestartParams
 	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
 
-	toolParams := []string{}
+	var toolParams []string
 	if params.Name != "" {
 		toolParams = append(toolParams, params.Name)
 	}

internal/ui/chat/messages.go 🔗

@@ -186,16 +186,18 @@ type AssistantInfoItem struct {
 	id                  string
 	message             *message.Message
 	sty                 *styles.Styles
+	cfg                 *config.Config
 	lastUserMessageTime time.Time
 }
 
 // NewAssistantInfoItem creates a new AssistantInfoItem.
-func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem {
+func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *config.Config, lastUserMessageTime time.Time) MessageItem {
 	return &AssistantInfoItem{
 		cachedMessageItem:   &cachedMessageItem{},
 		id:                  AssistantInfoID(message.ID),
 		message:             message,
 		sty:                 sty,
+		cfg:                 cfg,
 		lastUserMessageTime: lastUserMessageTime,
 	}
 }
@@ -231,13 +233,13 @@ func (a *AssistantInfoItem) renderContent(width int) string {
 	duration := finishTime.Sub(a.lastUserMessageTime)
 	infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String())
 	icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon)
-	model := config.Get().GetModel(a.message.Provider, a.message.Model)
+	model := a.cfg.GetModel(a.message.Provider, a.message.Model)
 	if model == nil {
 		model = &catwalk.Model{Name: "Unknown Model"}
 	}
 	modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name)
 	providerName := a.message.Provider
-	if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok {
+	if providerConfig, ok := a.cfg.Providers.Get(a.message.Provider); ok {
 		providerName = providerConfig.Name
 	}
 	provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))

internal/ui/chat/tools.go 🔗

@@ -588,19 +588,17 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
 	numFmt := fmt.Sprintf("%%%dd", maxDigits)
 
 	bodyWidth := width - toolBodyLeftPaddingTotal
-	codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
+	codeWidth := bodyWidth - maxDigits
 
 	var out []string
 	for i, ln := range highlightedLines {
 		lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
 
-		if lipgloss.Width(ln) > codeWidth {
-			ln = ansi.Truncate(ln, codeWidth, "…")
-		}
+		// Truncate accounting for padding that will be added.
+		ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
 
 		codeLine := sty.Tool.ContentCodeLine.
 			Width(codeWidth).
-			PaddingLeft(2).
 			Render(ln)
 
 		out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
@@ -609,7 +607,7 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
 	// Add truncation message if needed.
 	if len(lines) > maxLines && !expanded {
 		out = append(out, sty.Tool.ContentCodeTruncation.
-			Width(bodyWidth).
+			Width(width).
 			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
 		)
 	}

internal/ui/common/capabilities.go 🔗

@@ -47,7 +47,7 @@ func (c *Capabilities) Update(msg any) {
 	case tea.WindowSizeMsg:
 		c.Columns = m.Width
 		c.Rows = m.Height
-	case uv.WindowPixelSizeEvent:
+	case uv.PixelSizeEvent:
 		c.PixelX = m.Width
 		c.PixelY = m.Height
 	case uv.KittyGraphicsEvent:
@@ -71,6 +71,7 @@ func (c *Capabilities) Update(msg any) {
 func QueryCmd(env uv.Environ) tea.Cmd {
 	var sb strings.Builder
 	sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
+	sb.WriteString(ansi.QueryModifyOtherKeys)
 
 	// Queries that should only be sent to "smart" normal terminals.
 	shouldQueryFor := shouldQueryCapabilities(env)

internal/ui/common/common.go 🔗

@@ -10,7 +10,7 @@ import (
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
@@ -95,6 +95,6 @@ func CopyToClipboardWithCallback(text, successMessage string, callback tea.Cmd)
 			return nil
 		},
 		callback,
-		uiutil.ReportInfo(successMessage),
+		util.ReportInfo(successMessage),
 	)
 }

internal/ui/common/diff.go 🔗

@@ -2,7 +2,7 @@ package common
 
 import (
 	"github.com/alecthomas/chroma/v2"
-	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/crush/internal/ui/diffview"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
 

internal/ui/common/elements.go 🔗

@@ -99,7 +99,7 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
 	formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
 	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
 	if percentage > 80 {
-		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
+		formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)
 	}
 
 	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
@@ -137,7 +137,7 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string {
 		description = t.Base.Foreground(descriptionColor).Render(description)
 	}
 
-	content := []string{}
+	var content []string
 	if icon != "" {
 		content = append(content, icon)
 	}

internal/ui/completions/completions.go 🔗

@@ -1,12 +1,15 @@
 package completions
 
 import (
+	"cmp"
 	"slices"
 	"strings"
+	"sync"
 
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/x/ansi"
@@ -21,17 +24,18 @@ const (
 )
 
 // SelectionMsg is sent when a completion is selected.
-type SelectionMsg struct {
-	Value  any
-	Insert bool // If true, insert without closing.
+type SelectionMsg[T any] struct {
+	Value    T
+	KeepOpen bool // If true, insert without closing.
 }
 
 // ClosedMsg is sent when the completions are closed.
 type ClosedMsg struct{}
 
-// FilesLoadedMsg is sent when files have been loaded for completions.
-type FilesLoadedMsg struct {
-	Files []string
+// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
+type CompletionItemsLoadedMsg struct {
+	Files     []FileCompletionValue
+	Resources []ResourceCompletionValue
 }
 
 // Completions represents the completions popup component.
@@ -92,23 +96,43 @@ func (c *Completions) KeyMap() KeyMap {
 	return c.keyMap
 }
 
-// OpenWithFiles opens the completions with file items from the filesystem.
-func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
+// Open opens the completions with file items from the filesystem.
+func (c *Completions) Open(depth, limit int) tea.Cmd {
 	return func() tea.Msg {
-		files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
-		slices.Sort(files)
-		return FilesLoadedMsg{Files: files}
+		var msg CompletionItemsLoadedMsg
+		var wg sync.WaitGroup
+		wg.Go(func() {
+			msg.Files = loadFiles(depth, limit)
+		})
+		wg.Go(func() {
+			msg.Resources = loadMCPResources()
+		})
+		wg.Wait()
+		return msg
 	}
 }
 
-// SetFiles sets the file items on the completions popup.
-func (c *Completions) SetFiles(files []string) {
-	items := make([]list.FilterableItem, 0, len(files))
+// SetItems sets the files and MCP resources and rebuilds the merged list.
+func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
+	items := make([]list.FilterableItem, 0, len(files)+len(resources))
+
+	// Add files first.
 	for _, file := range files {
-		file = strings.TrimPrefix(file, "./")
 		item := NewCompletionItem(
+			file.Path,
 			file,
-			FileCompletionValue{Path: file},
+			c.normalStyle,
+			c.focusedStyle,
+			c.matchStyle,
+		)
+		items = append(items, item)
+	}
+
+	// Add MCP resources.
+	for _, resource := range resources {
+		item := NewCompletionItem(
+			resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
+			resource,
 			c.normalStyle,
 			c.focusedStyle,
 			c.matchStyle,
@@ -119,7 +143,7 @@ func (c *Completions) SetFiles(files []string) {
 	c.open = true
 	c.query = ""
 	c.list.SetItems(items...)
-	c.list.SetFilter("") // Clear any previous filter.
+	c.list.SetFilter("")
 	c.list.Focus()
 
 	c.width = maxWidth
@@ -128,16 +152,7 @@ func (c *Completions) SetFiles(files []string) {
 	c.list.SelectFirst()
 	c.list.ScrollToSelected()
 
-	// recalculate width by using just the visible items
-	start, end := c.list.VisibleItemIndices()
-	width := 0
-	if end != 0 {
-		for _, file := range files[start : end+1] {
-			width = max(width, ansi.StringWidth(file))
-		}
-	}
-	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
-	c.list.SetSize(c.width, c.height)
+	c.updateSize()
 }
 
 // Close closes the completions popup.
@@ -158,14 +173,20 @@ func (c *Completions) Filter(query string) {
 	c.query = query
 	c.list.SetFilter(query)
 
-	// recalculate width by using just the visible items
+	c.updateSize()
+}
+
+func (c *Completions) updateSize() {
 	items := c.list.FilteredItems()
 	start, end := c.list.VisibleItemIndices()
 	width := 0
-	if end != 0 {
-		for _, item := range items[start : end+1] {
-			width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text()))
+	for i := start; i <= end; i++ {
+		item := c.list.ItemAt(i)
+		if item == nil {
+			continue
 		}
+		s := item.(interface{ Text() string }).Text()
+		width = max(width, ansi.StringWidth(s))
 	}
 	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
 	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
@@ -238,7 +259,7 @@ func (c *Completions) selectNext() {
 }
 
 // selectCurrent returns a command with the currently selected item.
-func (c *Completions) selectCurrent(insert bool) tea.Msg {
+func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
 	items := c.list.FilteredItems()
 	if len(items) == 0 {
 		return nil
@@ -254,13 +275,23 @@ func (c *Completions) selectCurrent(insert bool) tea.Msg {
 		return nil
 	}
 
-	if !insert {
+	if !keepOpen {
 		c.open = false
 	}
 
-	return SelectionMsg{
-		Value:  item.Value(),
-		Insert: insert,
+	switch item := item.Value().(type) {
+	case ResourceCompletionValue:
+		return SelectionMsg[ResourceCompletionValue]{
+			Value:    item,
+			KeepOpen: keepOpen,
+		}
+	case FileCompletionValue:
+		return SelectionMsg[FileCompletionValue]{
+			Value:    item,
+			KeepOpen: keepOpen,
+		}
+	default:
+		return nil
 	}
 }
 
@@ -277,3 +308,30 @@ func (c *Completions) Render() string {
 
 	return c.list.Render()
 }
+
+func loadFiles(depth, limit int) []FileCompletionValue {
+	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
+	slices.Sort(files)
+	result := make([]FileCompletionValue, 0, len(files))
+	for _, file := range files {
+		result = append(result, FileCompletionValue{
+			Path: strings.TrimPrefix(file, "./"),
+		})
+	}
+	return result
+}
+
+func loadMCPResources() []ResourceCompletionValue {
+	var resources []ResourceCompletionValue
+	for mcpName, mcpResources := range mcp.Resources() {
+		for _, r := range mcpResources {
+			resources = append(resources, ResourceCompletionValue{
+				MCPName:  mcpName,
+				URI:      r.URI,
+				Title:    r.Name,
+				MIMEType: r.MIMEType,
+			})
+		}
+	}
+	return resources
+}

internal/ui/completions/item.go 🔗

@@ -13,6 +13,14 @@ type FileCompletionValue struct {
 	Path string
 }
 
+// ResourceCompletionValue represents a MCP resource completion value.
+type ResourceCompletionValue struct {
+	MCPName  string
+	URI      string
+	Title    string
+	MIMEType string
+}
+
 // CompletionItem represents an item in the completions list.
 type CompletionItem struct {
 	text    string

internal/ui/dialog/actions.go 🔗

@@ -15,7 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 )
 
 // ActionClose is a message to close the current dialog.
@@ -36,9 +36,10 @@ type ActionSelectSession struct {
 
 // ActionSelectModel is a message indicating a model has been selected.
 type ActionSelectModel struct {
-	Provider  catwalk.Provider
-	Model     config.SelectedModel
-	ModelType config.SelectedModelType
+	Provider       catwalk.Provider
+	Model          config.SelectedModel
+	ModelType      config.SelectedModelType
+	ReAuthenticate bool
 }
 
 // Messages for commands
@@ -131,22 +132,22 @@ func (a ActionFilePickerSelected) Cmd() tea.Cmd {
 	return func() tea.Msg {
 		isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
 		if err != nil {
-			return uiutil.InfoMsg{
-				Type: uiutil.InfoTypeError,
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
 				Msg:  fmt.Sprintf("unable to read the image: %v", err),
 			}
 		}
 		if isFileLarge {
-			return uiutil.InfoMsg{
-				Type: uiutil.InfoTypeError,
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
 				Msg:  "file too large, max 5MB",
 			}
 		}
 
 		content, err := os.ReadFile(path)
 		if err != nil {
-			return uiutil.InfoMsg{
-				Type: uiutil.InfoTypeError,
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
 				Msg:  fmt.Sprintf("unable to read the image: %v", err),
 			}
 		}

internal/ui/dialog/api_key_input.go 🔗

@@ -14,7 +14,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/exp/charmtone"
 )
@@ -76,7 +76,7 @@ func NewAPIKeyInput(
 
 	m.input = textinput.New()
 	m.input.SetVirtualCursor(false)
-	m.input.Placeholder = "Enter you API key..."
+	m.input.Placeholder = "Enter your API key..."
 	m.input.SetStyles(com.Styles.TextInput)
 	m.input.Focus()
 	m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
@@ -256,7 +256,7 @@ func (m *APIKeyInput) inputView() string {
 		ts := t.TextInput
 		ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry)
 
-		m.input.Prompt = styles.ErrorIcon + " "
+		m.input.Prompt = styles.LSPErrorIcon + " "
 		m.input.SetStyles(ts)
 		m.input.Focus()
 	}
@@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
 		Type:    m.provider.Type,
 		BaseURL: m.provider.APIEndpoint,
 	}
-	err := providerConfig.TestConnection(config.Get().Resolver())
+	err := providerConfig.TestConnection(m.com.Config().Resolver())
 
 	// intentionally wait for at least 750ms to make sure the user sees the spinner
 	elapsed := time.Since(start)
@@ -316,7 +316,7 @@ func (m *APIKeyInput) saveKeyAndContinue() Action {
 
 	err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
 	if err != nil {
-		return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+		return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
 	}
 
 	return ActionSelectModel{

internal/ui/dialog/arguments.go 🔗

@@ -15,7 +15,7 @@ import (
 
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/ui/common"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
@@ -202,7 +202,7 @@ func (a *Arguments) HandleMsg(msg tea.Msg) Action {
 				for i, arg := range a.arguments {
 					args[arg.ID] = a.inputs[i].Value()
 					if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
-						warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.")
+						warning = util.ReportWarn("Required argument '" + arg.Title + "' is missing.")
 						break
 					}
 				}
@@ -342,7 +342,7 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	if scrollbar != "" {
 		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
 	}
-	contentParts := []string{}
+	var contentParts []string
 	if description != "" {
 		contentParts = append(contentParts, description)
 	}

internal/ui/dialog/common.go 🔗

@@ -136,9 +136,10 @@ func (rc *RenderContext) Render() string {
 		if rc.Gap > 0 {
 			parts = append(parts, make([]string, rc.Gap)...)
 		}
+		helpWidth := rc.Width - dialogStyle.GetHorizontalFrameSize()
 		helpStyle := rc.Styles.Dialog.HelpView
-		helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
-		helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "")
+		helpStyle = helpStyle.Width(helpWidth)
+		helpView := ansi.Truncate(helpStyle.Render(rc.Help), helpWidth-1, "")
 		parts = append(parts, helpView)
 	}
 

internal/ui/dialog/models.go 🔗

@@ -4,7 +4,6 @@ import (
 	"cmp"
 	"fmt"
 	"slices"
-	"strings"
 
 	"charm.land/bubbles/v2/help"
 	"charm.land/bubbles/v2/key"
@@ -13,8 +12,9 @@ import (
 	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	uv "github.com/charmbracelet/ultraviolet"
+	xslice "github.com/charmbracelet/x/exp/slice"
 )
 
 // ModelType represents the type of model to select.
@@ -70,7 +70,7 @@ const (
 // ModelsID is the identifier for the model selection dialog.
 const ModelsID = "models"
 
-const defaultModelsDialogMaxWidth = 70
+const defaultModelsDialogMaxWidth = 73
 
 // Models represents a model selection dialog.
 type Models struct {
@@ -84,6 +84,7 @@ type Models struct {
 		Tab      key.Binding
 		UpDown   key.Binding
 		Select   key.Binding
+		Edit     key.Binding
 		Next     key.Binding
 		Previous key.Binding
 		Close    key.Binding
@@ -124,6 +125,10 @@ func NewModels(com *common.Common, isOnboarding bool) (*Models, error) {
 		key.WithKeys("enter", "ctrl+y"),
 		key.WithHelp("enter", "confirm"),
 	)
+	m.keyMap.Edit = key.NewBinding(
+		key.WithKeys("ctrl+e"),
+		key.WithHelp("ctrl+e", "edit"),
+	)
 	m.keyMap.UpDown = key.NewBinding(
 		key.WithKeys("up", "down"),
 		key.WithHelp("↑/↓", "choose"),
@@ -138,12 +143,14 @@ func NewModels(com *common.Common, isOnboarding bool) (*Models, error) {
 	)
 	m.keyMap.Close = CloseKey
 
-	providers, err := getFilteredProviders(com.Config())
-	if err != nil {
-		return nil, fmt.Errorf("failed to get providers: %w", err)
-	}
-
-	m.providers = providers
+	m.providers = slices.Collect(
+		xslice.Map(
+			com.Config().Providers.Seq(),
+			func(pc config.ProviderConfig) catwalk.Provider {
+				return pc.ToProvider()
+			},
+		),
+	)
 	if err := m.setProviderItems(); err != nil {
 		return nil, fmt.Errorf("failed to set provider items: %w", err)
 	}
@@ -181,7 +188,7 @@ func (m *Models) HandleMsg(msg tea.Msg) Action {
 			}
 			m.list.SelectNext()
 			m.list.ScrollToSelected()
-		case key.Matches(msg, m.keyMap.Select):
+		case key.Matches(msg, m.keyMap.Select, m.keyMap.Edit):
 			selectedItem := m.list.SelectedItem()
 			if selectedItem == nil {
 				break
@@ -192,10 +199,13 @@ func (m *Models) HandleMsg(msg tea.Msg) Action {
 				break
 			}
 
+			isEdit := key.Matches(msg, m.keyMap.Edit)
+
 			return ActionSelectModel{
-				Provider:  modelItem.prov,
-				Model:     modelItem.SelectedModel(),
-				ModelType: modelItem.SelectedModelType(),
+				Provider:       modelItem.prov,
+				Model:          modelItem.SelectedModel(),
+				ModelType:      modelItem.SelectedModelType(),
+				ReAuthenticate: isEdit,
 			}
 		case key.Matches(msg, m.keyMap.Tab):
 			if m.isOnboarding {
@@ -207,7 +217,7 @@ func (m *Models) HandleMsg(msg tea.Msg) Action {
 				m.modelType = ModelTypeLarge
 			}
 			if err := m.setProviderItems(); err != nil {
-				return uiutil.ReportError(err)
+				return util.ReportError(err)
 			}
 		default:
 			var cmd tea.Cmd
@@ -309,27 +319,35 @@ func (m *Models) ShortHelp() []key.Binding {
 			m.keyMap.Select,
 		}
 	}
-	return []key.Binding{
+	h := []key.Binding{
 		m.keyMap.UpDown,
 		m.keyMap.Tab,
 		m.keyMap.Select,
-		m.keyMap.Close,
 	}
+	if m.isSelectedConfigured() {
+		h = append(h, m.keyMap.Edit)
+	}
+	h = append(h, m.keyMap.Close)
+	return h
 }
 
 // FullHelp returns the full help view.
 func (m *Models) FullHelp() [][]key.Binding {
-	return [][]key.Binding{
-		{
-			m.keyMap.Select,
-			m.keyMap.Next,
-			m.keyMap.Previous,
-			m.keyMap.Tab,
-		},
-		{
-			m.keyMap.Close,
-		},
+	return [][]key.Binding{m.ShortHelp()}
+}
+
+func (m *Models) isSelectedConfigured() bool {
+	selectedItem := m.list.SelectedItem()
+	if selectedItem == nil {
+		return false
+	}
+	modelItem, ok := selectedItem.(*ModelItem)
+	if !ok {
+		return false
 	}
+	providerID := string(modelItem.prov.ID)
+	_, isConfigured := m.com.Config().Providers.Get(providerID)
+	return isConfigured
 }
 
 // setProviderItems sets the provider items in the list.
@@ -505,27 +523,6 @@ func (m *Models) setProviderItems() error {
 	return nil
 }
 
-func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
-	providers, err := config.Providers(cfg)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get providers: %w", err)
-	}
-	var filteredProviders []catwalk.Provider
-	for _, p := range providers {
-		var (
-			isAzure         = p.ID == catwalk.InferenceProviderAzure
-			isCopilot       = p.ID == catwalk.InferenceProviderCopilot
-			isHyper         = string(p.ID) == "hyper"
-			hasAPIKeyEnv    = strings.HasPrefix(p.APIKey, "$")
-			_, isConfigured = cfg.Providers.Get(string(p.ID))
-		)
-		if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured {
-			filteredProviders = append(filteredProviders, p)
-		}
-	}
-	return filteredProviders, nil
-}
-
 func modelKey(providerID, modelID string) string {
 	if providerID == "" || modelID == "" {
 		return ""

internal/ui/dialog/oauth.go 🔗

@@ -14,7 +14,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/ui/common"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/pkg/browser"
 )
@@ -173,7 +173,7 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action {
 
 	case ActionOAuthErrored:
 		m.State = OAuthStateError
-		cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
+		cmd := tea.Batch(m.oAuthProvider.stopPolling, util.ReportError(msg.Error))
 		return ActionCmd{cmd}
 	}
 	return nil
@@ -352,7 +352,7 @@ func (d *OAuth) copyCode() tea.Cmd {
 	}
 	return tea.Sequence(
 		tea.SetClipboard(d.userCode),
-		uiutil.ReportInfo("Code copied to clipboard"),
+		util.ReportInfo("Code copied to clipboard"),
 	)
 }
 
@@ -368,7 +368,7 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
 			}
 			return nil
 		},
-		uiutil.ReportInfo("Code copied and URL opened"),
+		util.ReportInfo("Code copied and URL opened"),
 	)
 }
 
@@ -377,7 +377,7 @@ func (m *OAuth) saveKeyAndContinue() Action {
 
 	err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
 	if err != nil {
-		return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+		return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
 	}
 
 	return ActionSelectModel{

internal/ui/dialog/sessions.go 🔗

@@ -12,7 +12,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
@@ -182,7 +182,7 @@ func (s *Session) HandleMsg(msg tea.Msg) Action {
 				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeUpdating, s.sessions...)...)
 			case key.Matches(msg, s.keyMap.Delete):
 				if s.isCurrentSessionBusy() {
-					return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")}
+					return ActionCmd{util.ReportWarn("Agent is busy, please wait...")}
 				}
 				s.sessionsMode = sessionsModeDeleting
 				s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...)
@@ -353,7 +353,7 @@ func (s *Session) deleteSessionCmd(id string) tea.Cmd {
 	return func() tea.Msg {
 		err := s.com.App.Sessions.Delete(context.TODO(), id)
 		if err != nil {
-			return uiutil.NewErrorMsg(err)
+			return util.NewErrorMsg(err)
 		}
 		return nil
 	}
@@ -389,7 +389,7 @@ func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
 	return func() tea.Msg {
 		_, err := s.com.App.Sessions.Save(context.TODO(), session)
 		if err != nil {
-			return uiutil.NewErrorMsg(err)
+			return util.NewErrorMsg(err)
 		}
 		return nil
 	}

internal/tui/exp/diffview/diffview_test.go → internal/ui/diffview/diffview_test.go 🔗

@@ -7,7 +7,7 @@ import (
 	"testing"
 
 	"github.com/alecthomas/chroma/v2/styles"
-	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/crush/internal/ui/diffview"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/golden"
 )

internal/ui/image/image.go 🔗

@@ -12,7 +12,7 @@ import (
 	"sync"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/ansi/kitty"
 	"github.com/disintegration/imaging"
@@ -68,6 +68,13 @@ var (
 	cachedMutex  sync.RWMutex
 )
 
+// ResetCache clears the image cache, freeing all cached decoded images.
+func ResetCache() {
+	cachedMutex.Lock()
+	clear(cachedImages)
+	cachedMutex.Unlock()
+}
+
 // fitImage resizes the image to fit within the specified dimensions in
 // terminal cells, maintaining the aspect ratio.
 func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
@@ -169,8 +176,8 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
 			},
 		}); err != nil {
 			slog.Error("Failed to encode image for kitty graphics", "err", err)
-			return uiutil.InfoMsg{
-				Type: uiutil.InfoTypeError,
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
 				Msg:  "failed to encode image",
 			}
 		}

internal/ui/image/image_test.go 🔗

@@ -0,0 +1,46 @@
+package image
+
+import (
+	"image"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestResetCache(t *testing.T) {
+	t.Parallel()
+
+	cachedMutex.Lock()
+	cachedImages[imageKey{id: "a", cols: 10, rows: 10}] = cachedImage{
+		img:  image.NewRGBA(image.Rect(0, 0, 1, 1)),
+		cols: 10,
+		rows: 10,
+	}
+	cachedImages[imageKey{id: "b", cols: 20, rows: 20}] = cachedImage{
+		img:  image.NewRGBA(image.Rect(0, 0, 1, 1)),
+		cols: 20,
+		rows: 20,
+	}
+	cachedMutex.Unlock()
+
+	ResetCache()
+
+	cachedMutex.RLock()
+	length := len(cachedImages)
+	cachedMutex.RUnlock()
+
+	require.Equal(t, 0, length)
+}
+
+func TestResetIdempotent(t *testing.T) {
+	t.Parallel()
+
+	// Calling Reset on an empty cache should not panic.
+	ResetCache()
+
+	cachedMutex.RLock()
+	length := len(cachedImages)
+	cachedMutex.RUnlock()
+
+	require.Equal(t, 0, length)
+}

internal/ui/list/highlight.go 🔗

@@ -126,7 +126,7 @@ func HighlightBuffer(content string, area image.Rectangle, startLine, startCol,
 			}
 			cell := line.At(x)
 			if cell != nil {
-				line.Set(x, highlighter(x, y, cell))
+				highlighter(x, y, cell)
 			}
 		}
 	}

internal/ui/list/list.go 🔗

@@ -79,7 +79,7 @@ func (l *List) Gap() int {
 func (l *List) AtBottom() bool {
 	const margin = 2
 
-	if len(l.items) == 0 {
+	if len(l.items) == 0 || l.offsetIdx >= len(l.items)-1 {
 		return true
 	}
 
@@ -158,7 +158,7 @@ func (l *List) getItem(idx int) renderedItem {
 
 	rendered := item.Render(l.width)
 	rendered = strings.TrimRight(rendered, "\n")
-	height := countLines(rendered)
+	height := strings.Count(rendered, "\n") + 1
 	ri := renderedItem{
 		content: rendered,
 		height:  height,
@@ -190,13 +190,18 @@ func (l *List) ScrollBy(lines int) {
 	}
 
 	if lines > 0 {
+		if l.AtBottom() {
+			// Already at bottom
+			return
+		}
+
 		// Scroll down
 		l.offsetLine += lines
 		currentItem := l.getItem(l.offsetIdx)
 		for l.offsetLine >= currentItem.height {
 			l.offsetLine -= currentItem.height
 			if l.gap > 0 {
-				l.offsetLine -= l.gap
+				l.offsetLine = max(0, l.offsetLine-l.gap)
 			}
 
 			// Move to next item
@@ -219,14 +224,13 @@ func (l *List) ScrollBy(lines int) {
 		// Scroll up
 		l.offsetLine += lines // lines is negative
 		for l.offsetLine < 0 {
-			if l.offsetIdx <= 0 {
+			// Move to previous item
+			l.offsetIdx--
+			if l.offsetIdx < 0 {
 				// Reached top
 				l.ScrollToTop()
 				break
 			}
-
-			// Move to previous item
-			l.offsetIdx--
 			prevItem := l.getItem(l.offsetIdx)
 			totalHeight := prevItem.height
 			if l.gap > 0 {
@@ -642,11 +646,3 @@ func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
 
 	return -1, -1
 }
-
-// countLines counts the number of lines in a string.
-func countLines(s string) int {
-	if s == "" {
-		return 1
-	}
-	return strings.Count(s, "\n") + 1
-}

internal/ui/logo/logo.go 🔗

@@ -8,7 +8,7 @@ import (
 
 	"charm.land/lipgloss/v2"
 	"github.com/MakeNowJust/heredoc"
-	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/slice"
 )
@@ -34,7 +34,7 @@ type Opts struct {
 //
 // The compact argument determines whether it renders compact for the sidebar
 // or wider for the main pane.
-func Render(version string, compact bool, o Opts) string {
+func Render(s *styles.Styles, version string, compact bool, o Opts) string {
 	const charm = " Charm™"
 
 	fg := func(c color.Color, s string) string {
@@ -59,7 +59,7 @@ func Render(version string, compact bool, o Opts) string {
 	crushWidth := lipgloss.Width(crush)
 	b := new(strings.Builder)
 	for r := range strings.SplitSeq(crush, "\n") {
-		fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+		fmt.Fprintln(b, styles.ApplyForegroundGrad(s, r, o.TitleColorA, o.TitleColorB))
 	}
 	crush = b.String()
 
@@ -117,14 +117,13 @@ func Render(version string, compact bool, o Opts) string {
 
 // SmallRender renders a smaller version of the Crush logo, suitable for
 // smaller windows or sidebar usage.
-func SmallRender(width int) string {
-	t := styles.CurrentTheme()
-	title := t.S().Base.Foreground(t.Secondary).Render("Charm™")
-	title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
+func SmallRender(t *styles.Styles, width int) string {
+	title := t.Base.Foreground(t.Secondary).Render("Charm™")
+	title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad(t, "Crush", t.Secondary, t.Primary))
 	remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
 	if remainingWidth > 0 {
 		lines := strings.Repeat("╱", remainingWidth)
-		title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
+		title = fmt.Sprintf("%s %s", title, t.Base.Foreground(t.Primary).Render(lines))
 	}
 	return title
 }

internal/ui/model/chat.go 🔗

@@ -437,8 +437,12 @@ func (m *Chat) MessageItem(id string) chat.MessageItem {
 // ToggleExpandedSelectedItem expands the selected message item if it is expandable.
 func (m *Chat) ToggleExpandedSelectedItem() {
 	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
-		expandable.ToggleExpanded()
-		m.list.ScrollToIndex(m.list.Selected())
+		if !expandable.ToggleExpanded() {
+			m.list.ScrollToIndex(m.list.Selected())
+		}
+		if m.list.AtBottom() {
+			m.list.ScrollToBottom()
+		}
 	}
 }
 
@@ -544,9 +548,13 @@ func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
 		handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
 		// Toggle expansion if applicable.
 		if expandable, ok := selectedItem.(chat.Expandable); ok {
-			expandable.ToggleExpanded()
+			if !expandable.ToggleExpanded() {
+				m.list.ScrollToIndex(m.list.Selected())
+			}
+		}
+		if m.list.AtBottom() {
+			m.list.ScrollToBottom()
 		}
-		m.list.ScrollToIndex(m.list.Selected())
 		return handled
 	}
 
@@ -737,10 +745,7 @@ func (m *Chat) selectWord(itemIdx, x, itemY int) {
 	// Adjust x for the item's left padding (border + padding) to get content column.
 	// The mouse x is in viewport space, but we need content space for boundary detection.
 	offset := chat.MessageLeftPaddingTotal
-	contentX := x - offset
-	if contentX < 0 {
-		contentX = 0
-	}
+	contentX := max(x-offset, 0)
 
 	line := ansi.Strip(lines[itemY])
 	startCol, endCol := findWordBoundaries(line, contentX)

internal/ui/model/clipboard.go 🔗

@@ -0,0 +1,15 @@
+package model
+
+import "errors"
+
+type clipboardFormat int
+
+const (
+	clipboardFormatText clipboardFormat = iota
+	clipboardFormatImage
+)
+
+var (
+	errClipboardPlatformUnsupported = errors.New("clipboard operations are not supported on this platform")
+	errClipboardUnknownFormat       = errors.New("unknown clipboard format")
+)

internal/tui/components/chat/editor/clipboard_not_supported.go → internal/ui/model/clipboard_not_supported.go 🔗

@@ -1,6 +1,6 @@
 //go:build !(darwin || linux || windows) || arm || 386 || ios || android
 
-package editor
+package model
 
 func readClipboard(clipboardFormat) ([]byte, error) {
 	return nil, errClipboardPlatformUnsupported

internal/tui/components/chat/editor/clipboard_supported.go → internal/ui/model/clipboard_supported.go 🔗

@@ -1,6 +1,6 @@
 //go:build (linux || darwin || windows) && !arm && !386 && !ios && !android
 
-package editor
+package model
 
 import "github.com/aymanbagabas/go-nativeclipboard"
 

internal/ui/model/filter.go 🔗

@@ -0,0 +1,22 @@
+package model
+
+import (
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+)
+
+var lastMouseEvent time.Time
+
+func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
+	switch msg.(type) {
+	case tea.MouseWheelMsg, tea.MouseMotionMsg:
+		now := time.Now()
+		// trackpad is sending too many requests
+		if now.Sub(lastMouseEvent) < 15*time.Millisecond {
+			return nil
+		}
+		lastMouseEvent = now
+	}
+	return msg
+}

internal/ui/model/header.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -22,29 +23,64 @@ const (
 	rightPadding   = 1
 )
 
-// renderCompactHeader renders the compact header for the given session.
-func renderCompactHeader(
-	com *common.Common,
+type header struct {
+	// cached logo and compact logo
+	logo        string
+	compactLogo string
+
+	com     *common.Common
+	width   int
+	compact bool
+}
+
+// newHeader creates a new header model.
+func newHeader(com *common.Common) *header {
+	h := &header{
+		com: com,
+	}
+	t := com.Styles
+	h.compactLogo = t.Header.Charm.Render("Charm™") + " " +
+		styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary) + " "
+	return h
+}
+
+// drawHeader draws the header for the given session.
+func (h *header) drawHeader(
+	scr uv.Screen,
+	area uv.Rectangle,
 	session *session.Session,
-	lspClients *csync.Map[string, *lsp.Client],
+	compact bool,
 	detailsOpen bool,
 	width int,
-) string {
-	if session == nil || session.ID == "" {
-		return ""
+) {
+	t := h.com.Styles
+	if width != h.width || compact != h.compact {
+		h.logo = renderLogo(h.com.Styles, compact, width)
 	}
 
-	t := com.Styles
+	h.width = width
+	h.compact = compact
 
-	var b strings.Builder
+	if !compact || session == nil || h.com.App == nil {
+		uv.NewStyledString(h.logo).Draw(scr, area)
+		return
+	}
+
+	if session.ID == "" {
+		return
+	}
 
-	b.WriteString(t.Header.Charm.Render("Charm™"))
-	b.WriteString(" ")
-	b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
-	b.WriteString(" ")
+	var b strings.Builder
+	b.WriteString(h.compactLogo)
 
 	availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
-	details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
+	details := renderHeaderDetails(
+		h.com,
+		session,
+		h.com.App.LSPManager.Clients(),
+		detailsOpen,
+		availDetailWidth,
+	)
 
 	remainingWidth := width -
 		lipgloss.Width(b.String()) -
@@ -61,7 +97,9 @@ func renderCompactHeader(
 
 	b.WriteString(details)
 
-	return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
+	view := uv.NewStyledString(
+		t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String()))
+	view.Draw(scr, area)
 }
 
 // renderHeaderDetails renders the details section of the header.
@@ -82,11 +120,11 @@ func renderHeaderDetails(
 	}
 
 	if errorCount > 0 {
-		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
+		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, errorCount)))
 	}
 
-	agentCfg := config.Get().Agents[config.AgentCoder]
-	model := config.Get().GetModelByType(agentCfg.Model)
+	agentCfg := com.Config().Agents[config.AgentCoder]
+	model := com.Config().GetModelByType(agentCfg.Model)
 	percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
 	formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
 	parts = append(parts, formattedPercentage)

internal/ui/model/keys.go 🔗

@@ -9,6 +9,7 @@ type KeyMap struct {
 		OpenEditor  key.Binding
 		Newline     key.Binding
 		AddImage    key.Binding
+		PasteImage  key.Binding
 		MentionFile key.Binding
 		Commands    key.Binding
 
@@ -120,6 +121,10 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("ctrl+f"),
 		key.WithHelp("ctrl+f", "add image"),
 	)
+	km.Editor.PasteImage = key.NewBinding(
+		key.WithKeys("ctrl+v"),
+		key.WithHelp("ctrl+v", "paste image from clipboard"),
+	)
 	km.Editor.MentionFile = key.NewBinding(
 		key.WithKeys("@"),
 		key.WithHelp("@", "mention file"),

internal/ui/model/landing.go 🔗

@@ -4,7 +4,7 @@ import (
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/ui/common"
-	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/ultraviolet/layout"
 )
 
 // selectedLargeModel returns the currently selected large language model from
@@ -31,7 +31,7 @@ func (m *UI) landingView() string {
 	parts = append(parts, "", m.modelInfo(width))
 	infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
 
-	_, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
+	_, remainingHeightArea := layout.SplitVertical(m.layout.main, layout.Fixed(lipgloss.Height(infoSection)+1))
 
 	mcpLspSectionWidth := min(30, (width-1)/2)
 

internal/ui/model/lsp.go 🔗

@@ -31,7 +31,7 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
 
 	var lsps []LSPInfo
 	for _, state := range states {
-		client, ok := m.com.App.LSPClients.Get(state.Name)
+		client, ok := m.com.App.LSPManager.Clients().Get(state.Name)
 		if !ok {
 			continue
 		}
@@ -60,18 +60,18 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
 
 // lspDiagnostics formats diagnostic counts with appropriate icons and colors.
 func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
-	errs := []string{}
+	var errs []string
 	if diagnostics[protocol.SeverityError] > 0 {
-		errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s %d", styles.ErrorIcon, diagnostics[protocol.SeverityError])))
+		errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, diagnostics[protocol.SeverityError])))
 	}
 	if diagnostics[protocol.SeverityWarning] > 0 {
-		errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning])))
+		errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPWarningIcon, diagnostics[protocol.SeverityWarning])))
 	}
 	if diagnostics[protocol.SeverityHint] > 0 {
-		errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint])))
+		errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPHintIcon, diagnostics[protocol.SeverityHint])))
 	}
 	if diagnostics[protocol.SeverityInformation] > 0 {
-		errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation])))
+		errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPInfoIcon, diagnostics[protocol.SeverityInformation])))
 	}
 	return strings.Join(errs, " ")
 }
@@ -89,6 +89,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
 		var description string
 		var diagnostics string
 		switch l.State {
+		case lsp.StateStopped:
+			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
+			description = t.Subtle.Render("stopped")
 		case lsp.StateStarting:
 			icon = t.ItemBusyIcon.String()
 			description = t.Subtle.Render("starting...")
@@ -103,7 +106,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
 			}
 		case lsp.StateDisabled:
 			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
-			description = t.Subtle.Render("inactive")
+			description = t.Subtle.Render("disabled")
 		default:
 			icon = t.ItemOfflineIcon.String()
 		}

internal/ui/model/mcp.go 🔗

@@ -34,15 +34,18 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
 	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
 }
 
-// mcpCounts formats tool and prompt counts for display.
+// mcpCounts formats tool, prompt, and resource counts for display.
 func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
-	parts := []string{}
+	var parts []string
 	if counts.Tools > 0 {
 		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools)))
 	}
 	if counts.Prompts > 0 {
 		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
 	}
+	if counts.Resources > 0 {
+		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d resources", counts.Resources)))
+	}
 	return strings.Join(parts, " ")
 }
 

internal/ui/model/onboarding.go 🔗

@@ -13,13 +13,13 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/ui/common"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 )
 
 // markProjectInitialized marks the current project as initialized in the config.
 func (m *UI) markProjectInitialized() tea.Msg {
 	// TODO: handle error so we show it in the tui footer
-	err := config.MarkProjectInitialized()
+	err := config.MarkProjectInitialized(m.com.Config())
 	if err != nil {
 		slog.Error(err.Error())
 	}
@@ -57,7 +57,7 @@ func (m *UI) initializeProject() tea.Cmd {
 	initialize := func() tea.Msg {
 		initPrompt, err := agent.InitializePrompt(*cfg)
 		if err != nil {
-			return uiutil.InfoMsg{Type: uiutil.InfoTypeError, Msg: err.Error()}
+			return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
 		}
 		return sendMessageMsg{Content: initPrompt}
 	}

internal/ui/model/session.go 🔗

@@ -3,6 +3,7 @@ package model
 import (
 	"context"
 	"fmt"
+	"log/slog"
 	"path/filepath"
 	"slices"
 	"strings"
@@ -15,15 +16,39 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	"github.com/charmbracelet/x/ansi"
 )
 
 // loadSessionMsg is a message indicating that a session and its files have
 // been loaded.
 type loadSessionMsg struct {
-	session *session.Session
-	files   []SessionFile
+	session   *session.Session
+	files     []SessionFile
+	readFiles []string
+}
+
+// lspFilePaths returns deduplicated file paths from both modified and read
+// files for starting LSP servers.
+func (msg loadSessionMsg) lspFilePaths() []string {
+	seen := make(map[string]struct{}, len(msg.files)+len(msg.readFiles))
+	paths := make([]string, 0, len(msg.files)+len(msg.readFiles))
+	for _, f := range msg.files {
+		p := f.LatestVersion.Path
+		if _, ok := seen[p]; ok {
+			continue
+		}
+		seen[p] = struct{}{}
+		paths = append(paths, p)
+	}
+	for _, p := range msg.readFiles {
+		if _, ok := seen[p]; ok {
+			continue
+		}
+		seen[p] = struct{}{}
+		paths = append(paths, p)
+	}
+	return paths
 }
 
 // SessionFile tracks the first and latest versions of a file in a session,
@@ -43,63 +68,74 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
 	return func() tea.Msg {
 		session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
 		if err != nil {
-			// TODO: better error handling
-			return uiutil.ReportError(err)()
+			return util.ReportError(err)
 		}
 
-		files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
+		sessionFiles, err := m.loadSessionFiles(sessionID)
 		if err != nil {
-			// TODO: better error handling
-			return uiutil.ReportError(err)()
+			return util.ReportError(err)
 		}
 
-		filesByPath := make(map[string][]history.File)
-		for _, f := range files {
-			filesByPath[f.Path] = append(filesByPath[f.Path], f)
+		readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID)
+		if err != nil {
+			slog.Error("Failed to load read files for session", "error", err)
 		}
 
-		sessionFiles := make([]SessionFile, 0, len(filesByPath))
-		for _, versions := range filesByPath {
-			if len(versions) == 0 {
-				continue
-			}
-
-			first := versions[0]
-			last := versions[0]
-			for _, v := range versions {
-				if v.Version < first.Version {
-					first = v
-				}
-				if v.Version > last.Version {
-					last = v
-				}
-			}
+		return loadSessionMsg{
+			session:   &session,
+			files:     sessionFiles,
+			readFiles: readFiles,
+		}
+	}
+}
 
-			_, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
+func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
+	files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
+	if err != nil {
+		return nil, err
+	}
 
-			sessionFiles = append(sessionFiles, SessionFile{
-				FirstVersion:  first,
-				LatestVersion: last,
-				Additions:     additions,
-				Deletions:     deletions,
-			})
+	filesByPath := make(map[string][]history.File)
+	for _, f := range files {
+		filesByPath[f.Path] = append(filesByPath[f.Path], f)
+	}
+	sessionFiles := make([]SessionFile, 0, len(filesByPath))
+	for _, versions := range filesByPath {
+		if len(versions) == 0 {
+			continue
 		}
 
-		slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
-			if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
-				return -1
+		first := versions[0]
+		last := versions[0]
+		for _, v := range versions {
+			if v.Version < first.Version {
+				first = v
 			}
-			if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
-				return 1
+			if v.Version > last.Version {
+				last = v
 			}
-			return 0
+		}
+
+		_, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
+
+		sessionFiles = append(sessionFiles, SessionFile{
+			FirstVersion:  first,
+			LatestVersion: last,
+			Additions:     additions,
+			Deletions:     deletions,
 		})
+	}
 
-		return loadSessionMsg{
-			session: &session,
-			files:   sessionFiles,
+	slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
+		if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
+			return -1
 		}
-	}
+		if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
+			return 1
+		}
+		return 0
+	})
+	return sessionFiles, nil
 }
 
 // handleFileEvent processes file change events and updates the session file
@@ -110,59 +146,14 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd {
 	}
 
 	return func() tea.Msg {
-		existingIdx := -1
-		for i, sf := range m.sessionFiles {
-			if sf.FirstVersion.Path == file.Path {
-				existingIdx = i
-				break
-			}
-		}
-
-		if existingIdx == -1 {
-			newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1)
-			newFiles = append(newFiles, SessionFile{
-				FirstVersion:  file,
-				LatestVersion: file,
-				Additions:     0,
-				Deletions:     0,
-			})
-			newFiles = append(newFiles, m.sessionFiles...)
-
-			return loadSessionMsg{
-				session: m.session,
-				files:   newFiles,
-			}
-		}
-
-		updated := m.sessionFiles[existingIdx]
-
-		if file.Version < updated.FirstVersion.Version {
-			updated.FirstVersion = file
-		}
-
-		if file.Version > updated.LatestVersion.Version {
-			updated.LatestVersion = file
-		}
-
-		_, additions, deletions := diff.GenerateDiff(
-			updated.FirstVersion.Content,
-			updated.LatestVersion.Content,
-			updated.FirstVersion.Path,
-		)
-		updated.Additions = additions
-		updated.Deletions = deletions
-
-		newFiles := make([]SessionFile, 0, len(m.sessionFiles))
-		newFiles = append(newFiles, updated)
-		for i, sf := range m.sessionFiles {
-			if i != existingIdx {
-				newFiles = append(newFiles, sf)
-			}
+		sessionFiles, err := m.loadSessionFiles(m.session.ID)
+		// could not load session files
+		if err != nil {
+			return util.NewErrorMsg(err)
 		}
 
-		return loadSessionMsg{
-			session: m.session,
-			files:   newFiles,
+		return sessionFilesUpdatesMsg{
+			sessionFiles: sessionFiles,
 		}
 	}
 }
@@ -177,9 +168,15 @@ func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
 		title = common.Section(t, "Modified Files", width)
 	}
 	list := t.Subtle.Render("None")
-
-	if len(m.sessionFiles) > 0 {
-		list = fileList(t, cwd, m.sessionFiles, width, maxItems)
+	var filesWithChanges []SessionFile
+	for _, f := range m.sessionFiles {
+		if f.Additions == 0 && f.Deletions == 0 {
+			continue
+		}
+		filesWithChanges = append(filesWithChanges, f)
+	}
+	if len(filesWithChanges) > 0 {
+		list = fileList(t, cwd, filesWithChanges, width, maxItems)
 	}
 
 	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
@@ -187,21 +184,13 @@ func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
 
 // fileList renders a list of files with their diff statistics, truncating to
 // maxItems and showing a "...and N more" message if needed.
-func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
+func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, width, maxItems int) string {
 	if maxItems <= 0 {
 		return ""
 	}
 	var renderedFiles []string
 	filesShown := 0
 
-	var filesWithChanges []SessionFile
-	for _, f := range files {
-		if f.Additions == 0 && f.Deletions == 0 {
-			continue
-		}
-		filesWithChanges = append(filesWithChanges, f)
-	}
-
 	for _, f := range filesWithChanges {
 		// Skip files with no changes
 		if filesShown >= maxItems {
@@ -242,3 +231,18 @@ func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems
 
 	return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
 }
+
+// startLSPs starts LSP servers for the given file paths.
+func (m *UI) startLSPs(paths []string) tea.Cmd {
+	if len(paths) == 0 {
+		return nil
+	}
+
+	return func() tea.Msg {
+		ctx := context.Background()
+		for _, path := range paths {
+			m.com.App.LSPManager.Start(ctx, path)
+		}
+		return nil
+	}
+}

internal/ui/model/sidebar.go 🔗

@@ -8,6 +8,7 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/ultraviolet/layout"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
@@ -27,7 +28,7 @@ func (m *UI) modelInfo(width int) string {
 
 			// Only check reasoning if model can reason
 			if model.CatwalkCfg.CanReason {
-				if model.ModelCfg.ReasoningEffort == "" {
+				if len(model.CatwalkCfg.ReasoningLevels) == 0 {
 					if model.ModelCfg.Think {
 						reasoningInfo = "Thinking On"
 					} else {
@@ -117,7 +118,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
 	cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
 	sidebarLogo := m.sidebarLogo
 	if height < logoHeightBreakpoint {
-		sidebarLogo = logo.SmallRender(width)
+		sidebarLogo = logo.SmallRender(m.com.Styles, width)
 	}
 	blocks := []string{
 		sidebarLogo,
@@ -134,7 +135,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
 		blocks...,
 	)
 
-	_, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader)))
+	_, remainingHeightArea := layout.SplitVertical(m.layout.sidebar, layout.Fixed(lipgloss.Height(sidebarHeader)))
 	remainingHeight := remainingHeightArea.Dy() - 10
 	maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight)
 

internal/ui/model/status.go 🔗

@@ -7,7 +7,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/ui/common"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 )
@@ -21,7 +21,7 @@ type Status struct {
 	hideHelp bool
 	help     help.Model
 	helpKm   help.KeyMap
-	msg      uiutil.InfoMsg
+	msg      util.InfoMsg
 }
 
 // NewStatus creates a new status bar and help model.
@@ -35,13 +35,13 @@ func NewStatus(com *common.Common, km help.KeyMap) *Status {
 }
 
 // SetInfoMsg sets the status info message.
-func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) {
+func (s *Status) SetInfoMsg(msg util.InfoMsg) {
 	s.msg = msg
 }
 
 // ClearInfoMsg clears the status info message.
 func (s *Status) ClearInfoMsg() {
-	s.msg = uiutil.InfoMsg{}
+	s.msg = util.InfoMsg{}
 }
 
 // SetWidth sets the width of the status bar and help view.
@@ -79,19 +79,19 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
 	var indStyle lipgloss.Style
 	var msgStyle lipgloss.Style
 	switch s.msg.Type {
-	case uiutil.InfoTypeError:
+	case util.InfoTypeError:
 		indStyle = s.com.Styles.Status.ErrorIndicator
 		msgStyle = s.com.Styles.Status.ErrorMessage
-	case uiutil.InfoTypeWarn:
+	case util.InfoTypeWarn:
 		indStyle = s.com.Styles.Status.WarnIndicator
 		msgStyle = s.com.Styles.Status.WarnMessage
-	case uiutil.InfoTypeUpdate:
+	case util.InfoTypeUpdate:
 		indStyle = s.com.Styles.Status.UpdateIndicator
 		msgStyle = s.com.Styles.Status.UpdateMessage
-	case uiutil.InfoTypeInfo:
+	case util.InfoTypeInfo:
 		indStyle = s.com.Styles.Status.InfoIndicator
 		msgStyle = s.com.Styles.Status.InfoMessage
-	case uiutil.InfoTypeSuccess:
+	case util.InfoTypeSuccess:
 		indStyle = s.com.Styles.Status.SuccessIndicator
 		msgStyle = s.com.Styles.Status.SuccessMessage
 	}
@@ -109,6 +109,6 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
 // given TTL.
 func clearInfoMsgCmd(ttl time.Duration) tea.Cmd {
 	return tea.Tick(ttl, func(time.Time) tea.Msg {
-		return uiutil.ClearStatusMsg{}
+		return util.ClearStatusMsg{}
 	})
 }

internal/ui/model/ui.go 🔗

@@ -24,6 +24,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
+	agenttools "github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/commands"
@@ -41,11 +42,13 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/completions"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
+	fimage "github.com/charmbracelet/crush/internal/ui/image"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	"github.com/charmbracelet/crush/internal/version"
 	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/ultraviolet/layout"
 	"github.com/charmbracelet/ultraviolet/screen"
 	"github.com/charmbracelet/x/editor"
 )
@@ -97,6 +100,10 @@ type (
 	mcpPromptsLoadedMsg struct {
 		Prompts []commands.MCPPrompt
 	}
+	// mcpStateChangedMsg is sent when there is a change in MCP client states.
+	mcpStateChangedMsg struct {
+		states map[string]mcp.ClientInfo
+	}
 	// sendMessageMsg is sent to send a message.
 	// currently only used for mcp prompts.
 	sendMessageMsg struct {
@@ -109,6 +116,11 @@ type (
 
 	// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
 	copyChatHighlightMsg struct{}
+
+	// sessionFilesUpdatesMsg is sent when the files for this session have been updated
+	sessionFilesUpdatesMsg struct {
+		sessionFiles []SessionFile
+	}
 )
 
 // UI represents the main user interface model.
@@ -125,7 +137,7 @@ type UI struct {
 	// The width and height of the terminal in cells.
 	width  int
 	height int
-	layout layout
+	layout uiLayout
 
 	isTransparent bool
 
@@ -141,8 +153,7 @@ type UI struct {
 	// isCanceling tracks whether the user has pressed escape once to cancel.
 	isCanceling bool
 
-	// header is the last cached header logo
-	header string
+	header *header
 
 	// sendProgressBar instructs the TUI to send progress bar updates to the
 	// terminal.
@@ -261,12 +272,15 @@ func New(com *common.Common) *UI {
 		},
 	)
 
+	header := newHeader(com)
+
 	ui := &UI{
 		com:         com,
 		dialog:      dialog.NewOverlay(),
 		keyMap:      keyMap,
 		textarea:    ta,
 		chat:        ch,
+		header:      header,
 		completions: comp,
 		attachments: attachments,
 		todoSpinner: todoSpinner,
@@ -291,7 +305,7 @@ func New(com *common.Common) *UI {
 	desiredFocus := uiFocusEditor
 	if !com.Config().IsConfigured() {
 		desiredState = uiOnboarding
-	} else if n, _ := config.ProjectNeedsInitialization(); n {
+	} else if n, _ := config.ProjectNeedsInitialization(com.Config()); n {
 		desiredState = uiInitialize
 	}
 
@@ -325,6 +339,10 @@ func (m *UI) Init() tea.Cmd {
 
 // setState changes the UI state and focus.
 func (m *UI) setState(state uiState, focus uiFocusState) {
+	if state == uiLanding {
+		// Always turn off compact mode when going to landing
+		m.isCompact = false
+	}
 	m.state = state
 	m.focus = focus
 	// Changing the state may change layout, so update it.
@@ -343,18 +361,16 @@ func (m *UI) loadCustomCommands() tea.Cmd {
 }
 
 // loadMCPrompts loads the MCP prompts asynchronously.
-func (m *UI) loadMCPrompts() tea.Cmd {
-	return func() tea.Msg {
-		prompts, err := commands.LoadMCPPrompts()
-		if err != nil {
-			slog.Error("Failed to load MCP prompts", "error", err)
-		}
-		if prompts == nil {
-			// flag them as loaded even if there is none or an error
-			prompts = []commands.MCPPrompt{}
-		}
-		return mcpPromptsLoadedMsg{Prompts: prompts}
+func (m *UI) loadMCPrompts() tea.Msg {
+	prompts, err := commands.LoadMCPPrompts()
+	if err != nil {
+		slog.Error("Failed to load MCP prompts", "error", err)
+	}
+	if prompts == nil {
+		// flag them as loaded even if there is none or an error
+		prompts = []commands.MCPPrompt{}
 	}
+	return mcpPromptsLoadedMsg{Prompts: prompts}
 }
 
 // Update handles updates to the UI model.
@@ -383,9 +399,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.setState(uiChat, m.focus)
 		m.session = msg.session
 		m.sessionFiles = msg.files
+		cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
 		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 		if err != nil {
-			cmds = append(cmds, uiutil.ReportError(err))
+			cmds = append(cmds, util.ReportError(err))
 			break
 		}
 		if cmd := m.setSessionMessages(msgs); cmd != nil {
@@ -404,6 +421,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, m.loadPromptHistory())
 		m.updateLayoutAndSize()
 
+	case sessionFilesUpdatesMsg:
+		m.sessionFiles = msg.sessionFiles
+		var paths []string
+		for _, f := range msg.sessionFiles {
+			paths = append(paths, f.LatestVersion.Path)
+		}
+		cmds = append(cmds, m.startLSPs(paths))
+
 	case sendMessageMsg:
 		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
 
@@ -418,6 +443,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if ok {
 			commands.SetCustomCommands(m.customCommands)
 		}
+
+	case mcpStateChangedMsg:
+		m.mcpStates = msg.states
 	case mcpPromptsLoadedMsg:
 		m.mcpPrompts = msg.Prompts
 		dia := m.dialog.Dialog(dialog.CommandsID)
@@ -492,17 +520,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case pubsub.Event[app.LSPEvent]:
 		m.lspStates = app.GetLSPStates()
 	case pubsub.Event[mcp.Event]:
-		m.mcpStates = mcp.GetStates()
-		// check if all mcps are initialized
-		initialized := true
-		for _, state := range m.mcpStates {
-			if state.State == mcp.StateStarting {
-				initialized = false
-				break
-			}
-		}
-		if initialized && m.mcpPrompts == nil {
-			cmds = append(cmds, m.loadMCPrompts())
+		switch msg.Payload.Type {
+		case mcp.EventStateChanged:
+			return m, tea.Batch(
+				m.handleStateChanged(),
+				m.loadMCPrompts,
+			)
+		case mcp.EventPromptsListChanged:
+			return m, handleMCPPromptsEvent(msg.Payload.Name)
+		case mcp.EventToolsListChanged:
+			return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name)
+		case mcp.EventResourcesListChanged:
+			return m, handleMCPResourcesEvent(msg.Payload.Name)
 		}
 	case pubsub.Event[permission.PermissionRequest]:
 		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -684,21 +713,25 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 		}
 	case openEditorMsg:
+		var cmd tea.Cmd
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
-	case uiutil.InfoMsg:
+		m.textarea, cmd = m.textarea.Update(msg)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case util.InfoMsg:
 		m.status.SetInfoMsg(msg)
 		ttl := msg.TTL
 		if ttl <= 0 {
 			ttl = DefaultStatusTTL
 		}
 		cmds = append(cmds, clearInfoMsgCmd(ttl))
-	case uiutil.ClearStatusMsg:
+	case util.ClearStatusMsg:
 		m.status.ClearInfoMsg()
-	case completions.FilesLoadedMsg:
-		// Handle async file loading for completions.
+	case completions.CompletionItemsLoadedMsg:
 		if m.completionsOpen {
-			m.completions.SetFiles(msg.Files)
+			m.completions.SetItems(msg.Files, msg.Resources)
 		}
 	case uv.KittyGraphicsEvent:
 		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
@@ -758,7 +791,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 		case message.Assistant:
 			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
-				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0))
+				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 				items = append(items, infoItem)
 			}
 		default:
@@ -888,7 +921,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 		}
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
-			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
+			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 			m.chat.AppendMessages(infoItem)
 			if atBottom {
 				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
@@ -959,7 +992,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 
 	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
-			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
+			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 			m.chat.AppendMessages(newInfoItem)
 		}
 	}
@@ -1107,6 +1140,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			break
 		}
 
+		if m.dialog.ContainsDialog(dialog.FilePickerID) {
+			defer fimage.ResetCache()
+		}
+
 		m.dialog.CloseFrontDialog()
 
 		if isOnboarding {
@@ -1143,7 +1180,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionNewSession:
 		if m.isAgentBusy() {
-			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
 			break
 		}
 		if cmd := m.newSession(); cmd != nil {
@@ -1152,13 +1189,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionSummarize:
 		if m.isAgentBusy() {
-			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
 			break
 		}
 		cmds = append(cmds, func() tea.Msg {
 			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
 			if err != nil {
-				return uiutil.ReportError(err)()
+				return util.ReportError(err)()
 			}
 			return nil
 		})
@@ -1168,7 +1205,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionExternalEditor:
 		if m.isAgentBusy() {
-			cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+			cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
 			break
 		}
 		cmds = append(cmds, m.openEditor(m.textarea.Value()))
@@ -1180,32 +1217,32 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		cmds = append(cmds, func() tea.Msg {
 			cfg := m.com.Config()
 			if cfg == nil {
-				return uiutil.ReportError(errors.New("configuration not found"))()
+				return util.ReportError(errors.New("configuration not found"))()
 			}
 
 			agentCfg, ok := cfg.Agents[config.AgentCoder]
 			if !ok {
-				return uiutil.ReportError(errors.New("agent configuration not found"))()
+				return util.ReportError(errors.New("agent configuration not found"))()
 			}
 
 			currentModel := cfg.Models[agentCfg.Model]
 			currentModel.Think = !currentModel.Think
 			if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
-				return uiutil.ReportError(err)()
+				return util.ReportError(err)()
 			}
 			m.com.App.UpdateAgentModel(context.TODO())
 			status := "disabled"
 			if currentModel.Think {
 				status = "enabled"
 			}
-			return uiutil.NewInfoMsg("Thinking mode " + status)
+			return util.NewInfoMsg("Thinking mode " + status)
 		})
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionQuit:
 		cmds = append(cmds, tea.Quit)
 	case dialog.ActionInitializeProject:
 		if m.isAgentBusy() {
-			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
 			break
 		}
 		cmds = append(cmds, m.initializeProject())
@@ -1213,13 +1250,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 	case dialog.ActionSelectModel:
 		if m.isAgentBusy() {
-			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
 			break
 		}
 
 		cfg := m.com.Config()
 		if cfg == nil {
-			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
 			break
 		}
 
@@ -1230,11 +1267,11 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		)
 
 		// Attempt to import GitHub Copilot tokens from VSCode if available.
-		if isCopilot && !isConfigured() {
-			config.Get().ImportCopilot()
+		if isCopilot && !isConfigured() && !msg.ReAuthenticate {
+			m.com.Config().ImportCopilot()
 		}
 
-		if !isConfigured() {
+		if !isConfigured() || msg.ReAuthenticate {
 			m.dialog.CloseDialog(dialog.ModelsID)
 			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
 				cmds = append(cmds, cmd)
@@ -1243,23 +1280,23 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		}
 
 		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
-			cmds = append(cmds, uiutil.ReportError(err))
+			cmds = append(cmds, util.ReportError(err))
 		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
 			// Ensure small model is set is unset.
 			smallModel := m.com.App.GetDefaultSmallModel(providerID)
 			if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
-				cmds = append(cmds, uiutil.ReportError(err))
+				cmds = append(cmds, util.ReportError(err))
 			}
 		}
 
 		cmds = append(cmds, func() tea.Msg {
 			if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
-				return uiutil.ReportError(err)
+				return util.ReportError(err)
 			}
 
 			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
 
-			return uiutil.NewInfoMsg(modelMsg)
+			return util.NewInfoMsg(modelMsg)
 		})
 
 		m.dialog.CloseDialog(dialog.APIKeyInputID)
@@ -1270,37 +1307,37 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			m.setState(uiLanding, uiFocusEditor)
 			m.com.Config().SetupAgents()
 			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
-				cmds = append(cmds, uiutil.ReportError(err))
+				cmds = append(cmds, util.ReportError(err))
 			}
 		}
 	case dialog.ActionSelectReasoningEffort:
 		if m.isAgentBusy() {
-			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
 			break
 		}
 
 		cfg := m.com.Config()
 		if cfg == nil {
-			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
 			break
 		}
 
 		agentCfg, ok := cfg.Agents[config.AgentCoder]
 		if !ok {
-			cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
+			cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
 			break
 		}
 
 		currentModel := cfg.Models[agentCfg.Model]
 		currentModel.ReasoningEffort = msg.Effort
 		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
-			cmds = append(cmds, uiutil.ReportError(err))
+			cmds = append(cmds, util.ReportError(err))
 			break
 		}
 
 		cmds = append(cmds, func() tea.Msg {
 			m.com.App.UpdateAgentModel(context.TODO())
-			return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
+			return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
 		})
 		m.dialog.CloseDialog(dialog.ReasoningID)
 	case dialog.ActionPermissionResponse:
@@ -1321,6 +1358,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 				m.dialog.CloseDialog(dialog.FilePickerID)
 				return nil
 			},
+			func() tea.Msg {
+				fimage.ResetCache()
+				return nil
+			},
 		))
 
 	case dialog.ActionRunCustomCommand:
@@ -1361,7 +1402,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		}
 		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
 	default:
-		cmds = append(cmds, uiutil.CmdHandler(msg))
+		cmds = append(cmds, util.CmdHandler(msg))
 	}
 
 	return tea.Batch(cmds...)
@@ -1453,7 +1494,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			}
 		case key.Matches(msg, m.keyMap.Suspend):
 			if m.isAgentBusy() {
-				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+				cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
 				return true
 			}
 			cmds = append(cmds, tea.Suspend)
@@ -1499,12 +1540,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			if m.completionsOpen {
 				if msg, ok := m.completions.Update(msg); ok {
 					switch msg := msg.(type) {
-					case completions.SelectionMsg:
-						// Handle file completion selection.
-						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
-							cmds = append(cmds, m.insertFileCompletion(item.Path))
+					case completions.SelectionMsg[completions.FileCompletionValue]:
+						cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
+						if !msg.KeepOpen {
+							m.closeCompletions()
 						}
-						if !msg.Insert {
+					case completions.SelectionMsg[completions.ResourceCompletionValue]:
+						cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
+						if !msg.KeepOpen {
 							m.closeCompletions()
 						}
 					case completions.ClosedMsg:
@@ -1524,6 +1567,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					cmds = append(cmds, cmd)
 				}
 
+			case key.Matches(msg, m.keyMap.Editor.PasteImage):
+				cmds = append(cmds, m.pasteImageFromClipboard)
+
 			case key.Matches(msg, m.keyMap.Editor.SendMessage):
 				value := m.textarea.Value()
 				if before, ok := strings.CutSuffix(value, "\\"); ok {
@@ -1555,7 +1601,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					break
 				}
 				if m.isAgentBusy() {
-					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
 					break
 				}
 				if cmd := m.newSession(); cmd != nil {
@@ -1570,7 +1616,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				}
 			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
 				if m.isAgentBusy() {
-					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+					cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
 					break
 				}
 				cmds = append(cmds, m.openEditor(m.textarea.Value()))
@@ -1618,7 +1664,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 						m.completionsStartIndex = curIdx
 						m.completionsPositionStart = m.completionsPosition()
 						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
-						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
+						cmds = append(cmds, m.completions.Open(depth, limit))
 					}
 				}
 
@@ -1670,7 +1716,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					break
 				}
 				if m.isAgentBusy() {
-					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
 					break
 				}
 				m.focus = uiFocusEditor
@@ -1756,6 +1802,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+// drawHeader draws the header section of the UI.
+func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
+	m.header.drawHeader(
+		scr,
+		area,
+		m.session,
+		m.isCompact,
+		m.detailsOpen,
+		m.width,
+	)
+}
+
 // Draw implements [uv.Drawable] and draws the UI model.
 func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	layout := m.generateLayout(area.Dx(), area.Dy())
@@ -1770,22 +1828,19 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 
 	switch m.state {
 	case uiOnboarding:
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
+		m.drawHeader(scr, layout.header)
 
 		// NOTE: Onboarding flow will be rendered as dialogs below, but
 		// positioned at the bottom left of the screen.
 
 	case uiInitialize:
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
+		m.drawHeader(scr, layout.header)
 
 		main := uv.NewStyledString(m.initializeView())
 		main.Draw(scr, layout.main)
 
 	case uiLanding:
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
+		m.drawHeader(scr, layout.header)
 		main := uv.NewStyledString(m.landingView())
 		main.Draw(scr, layout.main)
 
@@ -1794,8 +1849,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 
 	case uiChat:
 		if m.isCompact {
-			header := uv.NewStyledString(m.header)
-			header.Draw(scr, layout.header)
+			m.drawHeader(scr, layout.header)
 		} else {
 			m.drawSidebar(scr, layout.sidebar)
 		}
@@ -2048,6 +2102,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 				[]key.Binding{
 					k.Editor.Newline,
 					k.Editor.AddImage,
+					k.Editor.PasteImage,
 					k.Editor.MentionFile,
 					k.Editor.OpenEditor,
 				},
@@ -2096,6 +2151,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 				[]key.Binding{
 					k.Editor.Newline,
 					k.Editor.AddImage,
+					k.Editor.PasteImage,
 					k.Editor.MentionFile,
 					k.Editor.OpenEditor,
 				},
@@ -2133,7 +2189,7 @@ func (m *UI) toggleCompactMode() tea.Cmd {
 
 	err := m.com.Config().SetCompactMode(m.forceCompactMode)
 	if err != nil {
-		return uiutil.ReportError(err)
+		return util.ReportError(err)
 	}
 
 	m.updateLayoutAndSize()
@@ -2172,21 +2228,16 @@ func (m *UI) updateSize() {
 
 	// Handle different app states
 	switch m.state {
-	case uiOnboarding, uiInitialize, uiLanding:
-		m.renderHeader(false, m.layout.header.Dx())
-
 	case uiChat:
-		if m.isCompact {
-			m.renderHeader(true, m.layout.header.Dx())
-		} else {
-			m.renderSidebarLogo(m.layout.sidebar.Dx())
+		if !m.isCompact {
+			m.cacheSidebarLogo(m.layout.sidebar.Dx())
 		}
 	}
 }
 
 // generateLayout calculates the layout rectangles for all UI components based
 // on the current UI state and terminal dimensions.
-func (m *UI) generateLayout(w, h int) layout {
+func (m *UI) generateLayout(w, h int) uiLayout {
 	// The screen area we're working with
 	area := image.Rect(0, 0, w, h)
 
@@ -2207,7 +2258,7 @@ func (m *UI) generateLayout(w, h int) layout {
 	}
 
 	// Add app margins
-	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
+	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
 	appRect.Min.Y += 1
 	appRect.Max.Y -= 1
 	helpRect.Min.Y -= 1
@@ -2220,7 +2271,7 @@ func (m *UI) generateLayout(w, h int) layout {
 		appRect.Max.X -= 1
 	}
 
-	layout := layout{
+	uiLayout := uiLayout{
 		area:   area,
 		status: helpRect,
 	}
@@ -2236,9 +2287,9 @@ func (m *UI) generateLayout(w, h int) layout {
 		// ------
 		// help
 
-		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
-		layout.header = headerRect
-		layout.main = mainRect
+		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
+		uiLayout.header = headerRect
+		uiLayout.main = mainRect
 
 	case uiLanding:
 		// Layout
@@ -2250,14 +2301,14 @@ func (m *UI) generateLayout(w, h int) layout {
 		// editor
 		// ------
 		// help
-		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
-		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
+		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
 		// Remove extra padding from editor (but keep it for header and main)
 		editorRect.Min.X -= 1
 		editorRect.Max.X += 1
-		layout.header = headerRect
-		layout.main = mainRect
-		layout.editor = editorRect
+		uiLayout.header = headerRect
+		uiLayout.main = mainRect
+		uiLayout.editor = editorRect
 
 	case uiChat:
 		if m.isCompact {
@@ -2271,28 +2322,28 @@ func (m *UI) generateLayout(w, h int) layout {
 			// ------
 			// help
 			const compactHeaderHeight = 1
-			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
+			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
 			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
-			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
-			layout.sessionDetails = sessionDetailsArea
-			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
+			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
+			uiLayout.sessionDetails = sessionDetailsArea
+			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
 			// Add one line gap between header and main content
 			mainRect.Min.Y += 1
-			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
 			mainRect.Max.X -= 1 // Add padding right
-			layout.header = headerRect
+			uiLayout.header = headerRect
 			pillsHeight := m.pillsAreaHeight()
 			if pillsHeight > 0 {
 				pillsHeight = min(pillsHeight, mainRect.Dy())
-				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
-				layout.main = chatRect
-				layout.pills = pillsRect
+				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
+				uiLayout.main = chatRect
+				uiLayout.pills = pillsRect
 			} else {
-				layout.main = mainRect
+				uiLayout.main = mainRect
 			}
 			// Add bottom margin to main
-			layout.main.Max.Y -= 1
-			layout.editor = editorRect
+			uiLayout.main.Max.Y -= 1
+			uiLayout.editor = editorRect
 		} else {
 			// Layout
 			//
@@ -2303,40 +2354,40 @@ func (m *UI) generateLayout(w, h int) layout {
 			// ----------
 			// help
 
-			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
+			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
 			// Add padding left
 			sideRect.Min.X += 1
-			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
 			mainRect.Max.X -= 1 // Add padding right
-			layout.sidebar = sideRect
+			uiLayout.sidebar = sideRect
 			pillsHeight := m.pillsAreaHeight()
 			if pillsHeight > 0 {
 				pillsHeight = min(pillsHeight, mainRect.Dy())
-				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
-				layout.main = chatRect
-				layout.pills = pillsRect
+				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
+				uiLayout.main = chatRect
+				uiLayout.pills = pillsRect
 			} else {
-				layout.main = mainRect
+				uiLayout.main = mainRect
 			}
 			// Add bottom margin to main
-			layout.main.Max.Y -= 1
-			layout.editor = editorRect
+			uiLayout.main.Max.Y -= 1
+			uiLayout.editor = editorRect
 		}
 	}
 
-	if !layout.editor.Empty() {
+	if !uiLayout.editor.Empty() {
 		// Add editor margins 1 top and bottom
 		if len(m.attachments.List()) == 0 {
-			layout.editor.Min.Y += 1
+			uiLayout.editor.Min.Y += 1
 		}
-		layout.editor.Max.Y -= 1
+		uiLayout.editor.Max.Y -= 1
 	}
 
-	return layout
+	return uiLayout
 }
 
-// layout defines the positioning of UI elements.
-type layout struct {
+// uiLayout defines the positioning of UI elements.
+type uiLayout struct {
 	// area is the overall available area.
 	area uv.Rectangle
 
@@ -2368,11 +2419,11 @@ type layout struct {
 func (m *UI) openEditor(value string) tea.Cmd {
 	tmpfile, err := os.CreateTemp("", "msg_*.md")
 	if err != nil {
-		return uiutil.ReportError(err)
+		return util.ReportError(err)
 	}
 	defer tmpfile.Close() //nolint:errcheck
 	if _, err := tmpfile.WriteString(value); err != nil {
-		return uiutil.ReportError(err)
+		return util.ReportError(err)
 	}
 	cmd, err := editor.Command(
 		"crush",
@@ -2383,18 +2434,18 @@ func (m *UI) openEditor(value string) tea.Cmd {
 		),
 	)
 	if err != nil {
-		return uiutil.ReportError(err)
+		return util.ReportError(err)
 	}
 	return tea.ExecProcess(cmd, func(err error) tea.Msg {
 		if err != nil {
-			return uiutil.ReportError(err)
+			return util.ReportError(err)
 		}
 		content, err := os.ReadFile(tmpfile.Name())
 		if err != nil {
-			return uiutil.ReportError(err)
+			return util.ReportError(err)
 		}
 		if len(content) == 0 {
-			return uiutil.ReportWarn("Message is empty")
+			return util.ReportWarn("Message is empty")
 		}
 		os.Remove(tmpfile.Name())
 		return openEditorMsg{
@@ -2454,24 +2505,29 @@ func (m *UI) closeCompletions() {
 	m.completions.Close()
 }
 
-// insertFileCompletion inserts the selected file path into the textarea,
-// replacing the @query, and adds the file as an attachment.
-func (m *UI) insertFileCompletion(path string) tea.Cmd {
+// insertCompletionText replaces the @query in the textarea with the given text.
+// Returns false if the replacement cannot be performed.
+func (m *UI) insertCompletionText(text string) bool {
 	value := m.textarea.Value()
-	word := m.textareaWord()
-
-	// Find the @ and query to replace.
 	if m.completionsStartIndex > len(value) {
-		return nil
+		return false
 	}
 
-	// Build the new value: everything before @, the path, everything after query.
+	word := m.textareaWord()
 	endIdx := min(m.completionsStartIndex+len(word), len(value))
-
-	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
+	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
 	m.textarea.SetValue(newValue)
 	m.textarea.MoveToEnd()
 	m.textarea.InsertRune(' ')
+	return true
+}
+
+// insertFileCompletion inserts the selected file path into the textarea,
+// replacing the @query, and adds the file as an attachment.
+func (m *UI) insertFileCompletion(path string) tea.Cmd {
+	if !m.insertCompletionText(path) {
+		return nil
+	}
 
 	return func() tea.Msg {
 		absPath, _ := filepath.Abs(path)
@@ -2506,6 +2562,61 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
 	}
 }
 
+// insertMCPResourceCompletion inserts the selected resource into the textarea,
+// replacing the @query, and adds the resource as an attachment.
+func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
+	displayText := item.Title
+	if displayText == "" {
+		displayText = item.URI
+	}
+
+	if !m.insertCompletionText(displayText) {
+		return nil
+	}
+
+	return func() tea.Msg {
+		contents, err := mcp.ReadResource(
+			context.Background(),
+			m.com.Config(),
+			item.MCPName,
+			item.URI,
+		)
+		if err != nil {
+			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
+			return nil
+		}
+		if len(contents) == 0 {
+			return nil
+		}
+
+		content := contents[0]
+		var data []byte
+		if content.Text != "" {
+			data = []byte(content.Text)
+		} else if len(content.Blob) > 0 {
+			data = content.Blob
+		}
+		if len(data) == 0 {
+			return nil
+		}
+
+		mimeType := item.MIMEType
+		if mimeType == "" && content.MIMEType != "" {
+			mimeType = content.MIMEType
+		}
+		if mimeType == "" {
+			mimeType = "text/plain"
+		}
+
+		return message.Attachment{
+			FilePath: item.URI,
+			FileName: displayText,
+			MimeType: mimeType,
+			Content:  data,
+		}
+	}
+}
+
 // completionsPosition returns the X and Y position for the completions popup.
 func (m *UI) completionsPosition() image.Point {
 	cur := m.textarea.Cursor()
@@ -2585,32 +2696,22 @@ func (m *UI) renderEditorView(width int) string {
 	)
 }
 
-// renderHeader renders and caches the header logo at the specified width.
-func (m *UI) renderHeader(compact bool, width int) {
-	if compact && m.session != nil && m.com.App != nil {
-		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
-	} else {
-		m.header = renderLogo(m.com.Styles, compact, width)
-	}
-}
-
-// renderSidebarLogo renders and caches the sidebar logo at the specified
-// width.
-func (m *UI) renderSidebarLogo(width int) {
+// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
+func (m *UI) cacheSidebarLogo(width int) {
 	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
 }
 
 // sendMessage sends a message with the given content and attachments.
 func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
 	if m.com.App.AgentCoordinator == nil {
-		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
+		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 	}
 
 	var cmds []tea.Cmd
 	if !m.hasSession() {
 		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
 		if err != nil {
-			return uiutil.ReportError(err)
+			return util.ReportError(err)
 		}
 		if m.forceCompactMode {
 			m.isCompact = true
@@ -2622,9 +2723,14 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 		m.setState(uiChat, m.focus)
 	}
 
-	for _, path := range m.sessionFileReads {
-		m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
-	}
+	ctx := context.Background()
+	cmds = append(cmds, func() tea.Msg {
+		for _, path := range m.sessionFileReads {
+			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
+			m.com.App.LSPManager.Start(ctx, path)
+		}
+		return nil
+	})
 
 	// Capture session ID to avoid race with main goroutine updating m.session.
 	sessionID := m.session.ID
@@ -2636,8 +2742,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 			if isCancelErr || isPermissionErr {
 				return nil
 			}
-			return uiutil.InfoMsg{
-				Type: uiutil.InfoTypeError,
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
 				Msg:  err.Error(),
 			}
 		}
@@ -2744,7 +2850,7 @@ func (m *UI) openModelsDialog() tea.Cmd {
 	isOnboarding := m.state == uiOnboarding
 	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
 	if err != nil {
-		return uiutil.ReportError(err)
+		return util.ReportError(err)
 	}
 
 	m.dialog.OpenDialog(modelsDialog)

internal/ui/styles/styles.go 🔗

@@ -12,16 +12,12 @@ import (
 	"charm.land/glamour/v2/ansi"
 	"charm.land/lipgloss/v2"
 	"github.com/alecthomas/chroma/v2"
-	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/crush/internal/ui/diffview"
 	"github.com/charmbracelet/x/exp/charmtone"
 )
 
 const (
 	CheckIcon   string = "✓"
-	ErrorIcon   string = "×"
-	WarningIcon string = "⚠"
-	InfoIcon    string = "ⓘ"
-	HintIcon    string = "∵"
 	SpinnerIcon string = "⋯"
 	LoadingIcon string = "⟳"
 	ModelIcon   string = "◇"
@@ -49,6 +45,11 @@ const (
 
 	ScrollbarThumb string = "┃"
 	ScrollbarTrack string = "│"
+
+	LSPErrorIcon   string = "E"
+	LSPWarningIcon string = "W"
+	LSPInfoIcon    string = "I"
+	LSPHintIcon    string = "H"
 )
 
 const (
@@ -1115,7 +1116,7 @@ func DefaultStyles() Styles {
 	// Content rendering - prepared styles that accept width parameter
 	s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
 	s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
-	s.Tool.ContentCodeLine = s.Base.Background(bgBase)
+	s.Tool.ContentCodeLine = s.Base.Background(bgBase).PaddingLeft(2)
 	s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2)
 	s.Tool.ContentCodeBg = bgBase
 	s.Tool.Body = base.PaddingLeft(2)

internal/uiutil/uiutil.go → internal/ui/util/util.go 🔗

@@ -1,7 +1,5 @@
-// Package uiutil provides utility functions for UI message handling.
-// TODO: Move to internal/ui/<appropriate_location> once the new UI migration
-// is finalized.
-package uiutil
+// Package util provides utility functions for UI message handling.
+package util
 
 import (
 	"context"

internal/uicmd/uicmd.go 🔗

@@ -1,314 +0,0 @@
-// Package uicmd provides functionality to load and handle custom commands
-// from markdown files and MCP prompts.
-// TODO: Move this into internal/ui after refactoring.
-// TODO: DELETE when we delete the old tui
-package uicmd
-
-import (
-	"cmp"
-	"context"
-	"fmt"
-	"io/fs"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strings"
-
-	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type CommandType uint
-
-func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
-
-const (
-	SystemCommands CommandType = iota
-	UserCommands
-	MCPPrompts
-)
-
-// Command represents a command that can be executed
-type Command struct {
-	ID          string
-	Title       string
-	Description string
-	Shortcut    string // Optional shortcut for the command
-	Handler     func(cmd Command) tea.Cmd
-}
-
-// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
-type ShowArgumentsDialogMsg struct {
-	CommandID   string
-	Description string
-	ArgNames    []string
-	OnSubmit    func(args map[string]string) tea.Cmd
-}
-
-// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
-type CloseArgumentsDialogMsg struct {
-	Submit    bool
-	CommandID string
-	Content   string
-	Args      map[string]string
-}
-
-const (
-	userCommandPrefix    = "user:"
-	projectCommandPrefix = "project:"
-)
-
-var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
-
-type commandLoader struct {
-	sources []commandSource
-}
-
-type commandSource struct {
-	path   string
-	prefix string
-}
-
-func LoadCustomCommands() ([]Command, error) {
-	return LoadCustomCommandsFromConfig(config.Get())
-}
-
-func LoadCustomCommandsFromConfig(cfg *config.Config) ([]Command, error) {
-	if cfg == nil {
-		return nil, fmt.Errorf("config not loaded")
-	}
-
-	loader := &commandLoader{
-		sources: buildCommandSources(cfg),
-	}
-
-	return loader.loadAll()
-}
-
-func buildCommandSources(cfg *config.Config) []commandSource {
-	var sources []commandSource
-
-	// XDG config directory
-	if dir := getXDGCommandsDir(); dir != "" {
-		sources = append(sources, commandSource{
-			path:   dir,
-			prefix: userCommandPrefix,
-		})
-	}
-
-	// Home directory
-	if home := home.Dir(); home != "" {
-		sources = append(sources, commandSource{
-			path:   filepath.Join(home, ".crush", "commands"),
-			prefix: userCommandPrefix,
-		})
-	}
-
-	// Project directory
-	sources = append(sources, commandSource{
-		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
-		prefix: projectCommandPrefix,
-	})
-
-	return sources
-}
-
-func getXDGCommandsDir() string {
-	xdgHome := os.Getenv("XDG_CONFIG_HOME")
-	if xdgHome == "" {
-		if home := home.Dir(); home != "" {
-			xdgHome = filepath.Join(home, ".config")
-		}
-	}
-	if xdgHome != "" {
-		return filepath.Join(xdgHome, "crush", "commands")
-	}
-	return ""
-}
-
-func (l *commandLoader) loadAll() ([]Command, error) {
-	var commands []Command
-
-	for _, source := range l.sources {
-		if cmds, err := l.loadFromSource(source); err == nil {
-			commands = append(commands, cmds...)
-		}
-	}
-
-	return commands, nil
-}
-
-func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
-	if err := ensureDir(source.path); err != nil {
-		return nil, err
-	}
-
-	var commands []Command
-
-	err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
-		if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
-			return err
-		}
-
-		cmd, err := l.loadCommand(path, source.path, source.prefix)
-		if err != nil {
-			return nil // Skip invalid files
-		}
-
-		commands = append(commands, cmd)
-		return nil
-	})
-
-	return commands, err
-}
-
-func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
-	content, err := os.ReadFile(path)
-	if err != nil {
-		return Command{}, err
-	}
-
-	id := buildCommandID(path, baseDir, prefix)
-	desc := fmt.Sprintf("Custom command from %s", filepath.Base(path))
-
-	return Command{
-		ID:          id,
-		Title:       id,
-		Description: desc,
-		Handler:     createCommandHandler(id, desc, string(content)),
-	}, nil
-}
-
-func buildCommandID(path, baseDir, prefix string) string {
-	relPath, _ := filepath.Rel(baseDir, path)
-	parts := strings.Split(relPath, string(filepath.Separator))
-
-	// Remove .md extension from last part
-	if len(parts) > 0 {
-		lastIdx := len(parts) - 1
-		parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
-	}
-
-	return prefix + strings.Join(parts, ":")
-}
-
-func createCommandHandler(id, desc, content string) func(Command) tea.Cmd {
-	return func(cmd Command) tea.Cmd {
-		args := extractArgNames(content)
-
-		if len(args) == 0 {
-			return util.CmdHandler(CommandRunCustomMsg{
-				Content: content,
-			})
-		}
-		return util.CmdHandler(ShowArgumentsDialogMsg{
-			CommandID:   id,
-			Description: desc,
-			ArgNames:    args,
-			OnSubmit: func(args map[string]string) tea.Cmd {
-				return execUserPrompt(content, args)
-			},
-		})
-	}
-}
-
-func execUserPrompt(content string, args map[string]string) tea.Cmd {
-	return func() tea.Msg {
-		for name, value := range args {
-			placeholder := "$" + name
-			content = strings.ReplaceAll(content, placeholder, value)
-		}
-		return CommandRunCustomMsg{
-			Content: content,
-		}
-	}
-}
-
-func extractArgNames(content string) []string {
-	matches := namedArgPattern.FindAllStringSubmatch(content, -1)
-	if len(matches) == 0 {
-		return nil
-	}
-
-	seen := make(map[string]bool)
-	var args []string
-
-	for _, match := range matches {
-		arg := match[1]
-		if !seen[arg] {
-			seen[arg] = true
-			args = append(args, arg)
-		}
-	}
-
-	return args
-}
-
-func ensureDir(path string) error {
-	if _, err := os.Stat(path); os.IsNotExist(err) {
-		return os.MkdirAll(path, 0o755)
-	}
-	return nil
-}
-
-func isMarkdownFile(name string) bool {
-	return strings.HasSuffix(strings.ToLower(name), ".md")
-}
-
-type CommandRunCustomMsg struct {
-	Content string
-}
-
-func LoadMCPPrompts() []Command {
-	var commands []Command
-	for mcpName, prompts := range mcp.Prompts() {
-		for _, prompt := range prompts {
-			key := mcpName + ":" + prompt.Name
-			commands = append(commands, Command{
-				ID:          key,
-				Title:       cmp.Or(prompt.Title, prompt.Name),
-				Description: prompt.Description,
-				Handler:     createMCPPromptHandler(mcpName, prompt.Name, prompt),
-			})
-		}
-	}
-
-	return commands
-}
-
-func createMCPPromptHandler(mcpName, promptName string, prompt *mcp.Prompt) func(Command) tea.Cmd {
-	return func(cmd Command) tea.Cmd {
-		if len(prompt.Arguments) == 0 {
-			return execMCPPrompt(mcpName, promptName, nil)
-		}
-		return util.CmdHandler(ShowMCPPromptArgumentsDialogMsg{
-			Prompt: prompt,
-			OnSubmit: func(args map[string]string) tea.Cmd {
-				return execMCPPrompt(mcpName, promptName, args)
-			},
-		})
-	}
-}
-
-func execMCPPrompt(clientName, promptName string, args map[string]string) tea.Cmd {
-	return func() tea.Msg {
-		ctx := context.Background()
-		result, err := mcp.GetPromptMessages(ctx, clientName, promptName, args)
-		if err != nil {
-			return util.ReportError(err)
-		}
-
-		return chat.SendMsg{
-			Text: strings.Join(result, " "),
-		}
-	}
-}
-
-type ShowMCPPromptArgumentsDialogMsg struct {
-	Prompt   *mcp.Prompt
-	OnSubmit func(arg map[string]string) tea.Cmd
-}

schema.json 🔗

@@ -92,7 +92,10 @@
         }
       },
       "additionalProperties": false,
-      "type": "object"
+      "type": "object",
+      "required": [
+        "tools"
+      ]
     },
     "LSPConfig": {
       "properties": {
@@ -716,7 +719,10 @@
         }
       },
       "additionalProperties": false,
-      "type": "object"
+      "type": "object",
+      "required": [
+        "ls"
+      ]
     }
   }
 }