refactor: remove old tui (#2008)

Kujtim Hoxha created

Change summary

go.mod                                                                                                                                                  |   11 
go.sum                                                                                                                                                  |   18 
internal/app/app.go                                                                                                                                     |    6 
internal/cmd/root.go                                                                                                                                    |   40 
internal/cmd/root_test.go                                                                                                                               |  160 
internal/format/spinner.go                                                                                                                              |   13 
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/clipboard_not_supported.go                                                                                          |    7 
internal/tui/components/chat/editor/clipboard_supported.go                                                                                              |   15 
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/common/common.go                                                                                                                            |    4 
internal/ui/common/diff.go                                                                                                                              |    2 
internal/ui/dialog/actions.go                                                                                                                           |   14 
internal/ui/dialog/api_key_input.go                                                                                                                     |    4 
internal/ui/dialog/arguments.go                                                                                                                         |    4 
internal/ui/dialog/models.go                                                                                                                            |    4 
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                                                                                                                              |    6 
internal/ui/logo/logo.go                                                                                                                                |   15 
internal/ui/model/filter.go                                                                                                                             |   22 
internal/ui/model/onboarding.go                                                                                                                         |    4 
internal/ui/model/session.go                                                                                                                            |    6 
internal/ui/model/sidebar.go                                                                                                                            |    2 
internal/ui/model/status.go                                                                                                                             |   20 
internal/ui/model/ui.go                                                                                                                                 |  104 
internal/ui/styles/styles.go                                                                                                                            |    2 
internal/ui/util/util.go                                                                                                                                |    6 
internal/uicmd/uicmd.go                                                                                                                                 |  314 
518 files changed, 144 insertions(+), 22,039 deletions(-)

Detailed changes

go.mod πŸ”—

@@ -16,7 +16,6 @@ require (
 	github.com/PuerkitoBio/goquery v1.11.0
 	github.com/alecthomas/chroma/v2 v2.23.1
 	github.com/atotto/clipboard v0.1.4
-	github.com/aymanbagabas/go-nativeclipboard v0.1.2
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.10.0
 	github.com/charlievieth/fastwalk v1.0.14
@@ -36,7 +35,6 @@ require (
 	github.com/clipperhouse/displaywidth v0.9.0
 	github.com/clipperhouse/uax29/v2 v2.5.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,9 +44,7 @@ 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
@@ -59,13 +55,10 @@ require (
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 	github.com/sahilm/fuzzy v0.1.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
 	golang.org/x/net v0.49.0
 	golang.org/x/sync v0.19.0
 	golang.org/x/text v0.33.0
@@ -100,7 +93,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
@@ -111,9 +103,7 @@ 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
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
 	github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -180,6 +170,7 @@ 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/mod v0.32.0 // indirect
 	golang.org/x/oauth2 v0.34.0 // indirect
 	golang.org/x/sys v0.40.0 // indirect
 	golang.org/x/term v0.39.0 // indirect

go.sum πŸ”—

@@ -80,10 +80,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
 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=
@@ -148,18 +144,12 @@ 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=
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
-github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
 github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
 github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
@@ -275,16 +265,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=
@@ -331,10 +317,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=

internal/app/app.go πŸ”—

@@ -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"
@@ -160,7 +160,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

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 {
@@ -313,18 +296,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/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/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/clipboard_supported.go πŸ”—

@@ -1,15 +0,0 @@
-//go:build (linux || darwin || windows) && !arm && !386 && !ios && !android
-
-package editor
-
-import "github.com/aymanbagabas/go-nativeclipboard"
-
-func readClipboard(f clipboardFormat) ([]byte, error) {
-	switch f {
-	case clipboardFormatText:
-		return nativeclipboard.Text.Read()
-	case clipboardFormatImage:
-		return nativeclipboard.Image.Read()
-	}
-	return nil, errClipboardUnknownFormat
-}

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/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.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/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/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/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.
@@ -131,22 +131,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"
 )
@@ -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
 					}
 				}

internal/ui/dialog/models.go πŸ”—

@@ -13,7 +13,7 @@ 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"
 )
 
@@ -207,7 +207,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

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"
@@ -169,8 +169,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/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/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/onboarding.go πŸ”—

@@ -13,7 +13,7 @@ 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.
@@ -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 πŸ”—

@@ -15,7 +15,7 @@ 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"
 )
 
@@ -44,13 +44,13 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
 		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)
 		if err != nil {
 			// TODO: better error handling
-			return uiutil.ReportError(err)()
+			return util.ReportError(err)()
 		}
 
 		filesByPath := make(map[string][]history.File)

internal/ui/model/sidebar.go πŸ”—

@@ -117,7 +117,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,

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 πŸ”—

@@ -43,7 +43,7 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/dialog"
 	"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/screen"
@@ -391,7 +391,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.sessionFiles = msg.files
 		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 {
@@ -697,14 +697,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
-	case uiutil.InfoMsg:
+	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.
@@ -1154,7 +1154,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 {
@@ -1163,13 +1163,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
 		})
@@ -1179,7 +1179,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()))
@@ -1191,32 +1191,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())
@@ -1224,13 +1224,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
 		}
 
@@ -1254,23 +1254,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)
@@ -1281,37 +1281,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:
@@ -1372,7 +1372,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...)
@@ -1464,7 +1464,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)
@@ -1566,7 +1566,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 {
@@ -1581,7 +1581,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()))
@@ -1681,7 +1681,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
@@ -2152,7 +2152,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()
@@ -2382,11 +2382,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",
@@ -2397,18 +2397,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{
@@ -2607,14 +2607,14 @@ func (m *UI) cacheSidebarLogo(width int) {
 // 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
@@ -2640,8 +2640,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(),
 			}
 		}
@@ -2748,7 +2748,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)
@@ -2771,7 +2771,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
 
 	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
 	if err != nil {
-		return uiutil.ReportError(err)
+		return util.ReportError(err)
 	}
 
 	m.dialog.OpenDialog(commands)
@@ -2788,7 +2788,7 @@ func (m *UI) openReasoningDialog() tea.Cmd {
 
 	reasoningDialog, err := dialog.NewReasoning(m.com)
 	if err != nil {
-		return uiutil.ReportError(err)
+		return util.ReportError(err)
 	}
 
 	m.dialog.OpenDialog(reasoningDialog)
@@ -2812,7 +2812,7 @@ func (m *UI) openSessionsDialog() tea.Cmd {
 
 	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
 	if err != nil {
-		return uiutil.ReportError(err)
+		return util.ReportError(err)
 	}
 
 	m.dialog.OpenDialog(dialog)
@@ -2902,7 +2902,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 		return func() tea.Msg {
 			content := []byte(msg.Content)
 			if int64(len(content)) > common.MaxAttachmentSize {
-				return uiutil.ReportWarn("Paste is too big (>5mb)")
+				return util.ReportWarn("Paste is too big (>5mb)")
 			}
 			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
 			mimeBufferSize := min(512, len(content))
@@ -2961,18 +2961,18 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd {
 	return func() tea.Msg {
 		fileInfo, err := os.Stat(path)
 		if err != nil {
-			return uiutil.ReportError(err)
+			return util.ReportError(err)
 		}
 		if fileInfo.IsDir() {
-			return uiutil.ReportWarn("Cannot attach a directory")
+			return util.ReportWarn("Cannot attach a directory")
 		}
 		if fileInfo.Size() > common.MaxAttachmentSize {
-			return uiutil.ReportWarn("File is too big (>5mb)")
+			return util.ReportWarn("File is too big (>5mb)")
 		}
 
 		content, err := os.ReadFile(path)
 		if err != nil {
-			return uiutil.ReportError(err)
+			return util.ReportError(err)
 		}
 
 		mimeBufferSize := min(512, len(content))
@@ -3059,7 +3059,7 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
 		prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
 		if err != nil {
 			// TODO: make this better
-			return uiutil.ReportError(err)()
+			return util.ReportError(err)()
 		}
 
 		if prompt == "" {
@@ -3095,7 +3095,7 @@ func (m *UI) copyChatHighlight() tea.Cmd {
 
 // renderLogo renders the Crush logo with the given styles and dimensions.
 func renderLogo(t *styles.Styles, compact bool, width int) string {
-	return logo.Render(version.Version, compact, logo.Opts{
+	return logo.Render(t, version.Version, compact, logo.Opts{
 		FieldColor:   t.LogoFieldColor,
 		TitleColorA:  t.LogoTitleColorA,
 		TitleColorB:  t.LogoTitleColorB,

internal/ui/styles/styles.go πŸ”—

@@ -12,7 +12,7 @@ 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"
 )
 

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
-}