From 9013bb012202eae93a936273782014f5c6ae35d2 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 5 Feb 2026 12:02:16 +0100 Subject: [PATCH] refactor: remove old tui (#2008) --- 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 -------- .../tui/components/chat/editor/clipboard.go | 8 - .../chat/editor/clipboard_not_supported.go | 7 - .../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 -- .../tui/components/chat/messages/messages.go | 461 ----- .../tui/components/chat/messages/renderer.go | 1403 ------------- internal/tui/components/chat/messages/tool.go | 877 -------- .../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 - .../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 -- .../AllFieldsWithExtraContent.golden | 1 - .../core/testdata/TestStatus/Default.golden | 1 - .../TestStatus/EmptyDescription.golden | 1 - .../TestStatus/LongDescription.golden | 1 - .../testdata/TestStatus/NarrowWidth.golden | 1 - .../core/testdata/TestStatus/NoIcon.golden | 1 - .../TestStatus/VeryNarrowWidth.golden | 1 - .../testdata/TestStatus/WithColors.golden | 1 - .../testdata/TestStatus/WithCustomIcon.golden | 1 - .../TestStatus/WithExtraContent.golden | 1 - .../TestStatusTruncation/Width20.golden | 1 - .../TestStatusTruncation/Width30.golden | 1 - .../TestStatusTruncation/Width40.golden | 1 - .../TestStatusTruncation/Width50.golden | 1 - .../TestStatusTruncation/Width60.golden | 1 - .../components/dialogs/commands/arguments.go | 245 --- .../components/dialogs/commands/commands.go | 479 ----- .../tui/components/dialogs/commands/keys.go | 133 -- .../components/dialogs/copilot/device_flow.go | 281 --- internal/tui/components/dialogs/dialogs.go | 165 -- .../dialogs/filepicker/filepicker.go | 260 --- .../tui/components/dialogs/filepicker/keys.go | 80 - .../components/dialogs/hyper/device_flow.go | 267 --- internal/tui/components/dialogs/keys.go | 43 - .../tui/components/dialogs/models/apikey.go | 203 -- .../tui/components/dialogs/models/keys.go | 120 -- .../tui/components/dialogs/models/list.go | 333 ---- .../dialogs/models/list_recent_test.go | 369 ---- .../tui/components/dialogs/models/models.go | 549 ----- .../components/dialogs/permissions/keys.go | 113 -- .../dialogs/permissions/permissions.go | 899 --------- internal/tui/components/dialogs/quit/keys.go | 75 - internal/tui/components/dialogs/quit/quit.go | 120 -- .../components/dialogs/reasoning/reasoning.go | 264 --- .../tui/components/dialogs/sessions/keys.go | 67 - .../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 ------ ...hould_create_simple_filterable_list.golden | 10 - ...o_to_selected_item_at_the_beginning.golden | 10 - ...ted_item_at_the_beginning_backwards.golden | 10 - ...n_list_that_does_not_fits_the_items.golden | 10 - ..._the_items_and_has_multi_line_items.golden | 10 - ..._and_has_multi_line_items_backwards.golden | 10 - ...t_does_not_fits_the_items_backwards.golden | 10 - ...sitions_in_list_that_fits_the_items.golden | 20 - ..._list_that_fits_the_items_backwards.golden | 20 - .../should_move_viewport_down.golden | 10 - .../should_move_viewport_down_and_up.golden | 10 - .../should_move_viewport_up.golden | 10 - .../should_move_viewport_up_and_down.golden | 10 - ...are_at_the_bottom_in_backwards_list.golden | 10 - ...d_we_are_at_the_top_in_forward_list.golden | 10 - ...appended_and_we_are_in_forward_list.golden | 10 - ...pended_and_we_are_in_backwards_list.golden | 10 - ...d_but_we_moved_down_in_forward_list.golden | 10 - ...d_but_we_moved_up_in_backwards_list.golden | 10 - ..._above_is_decreases_in_forward_list.golden | 10 - ...bove_is_increased_in_backwards_list.golden | 10 - ..._above_is_increased_in_forward_list.golden | 10 - ...elow_is_decreases_in_backwards_list.golden | 10 - ...elow_is_increased_in_backwards_list.golden | 10 - ..._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 +- .../{tui/exp => ui}/diffview/Taskfile.yaml | 0 internal/{tui/exp => ui}/diffview/chroma.go | 0 internal/{tui/exp => ui}/diffview/diffview.go | 0 .../{tui/exp => ui}/diffview/diffview_test.go | 2 +- internal/{tui/exp => ui}/diffview/split.go | 0 internal/{tui/exp => ui}/diffview/style.go | 0 .../diffview/testdata/TestDefault.after | 0 .../diffview/testdata/TestDefault.before | 0 .../Split/CustomContextLines/DarkMode.golden | 0 .../Split/CustomContextLines/LightMode.golden | 0 .../Split/Default/DarkMode.golden | 0 .../Split/Default/LightMode.golden | 0 .../Split/LargeWidth/DarkMode.golden | 0 .../Split/LargeWidth/LightMode.golden | 0 .../Split/MultipleHunks/DarkMode.golden | 0 .../Split/MultipleHunks/LightMode.golden | 0 .../TestDiffView/Split/Narrow/DarkMode.golden | 0 .../Split/Narrow/LightMode.golden | 0 .../Split/NoLineNumbers/DarkMode.golden | 0 .../Split/NoLineNumbers/LightMode.golden | 0 .../Split/NoSyntaxHighlight/DarkMode.golden | 0 .../Split/NoSyntaxHighlight/LightMode.golden | 0 .../Split/SmallWidth/DarkMode.golden | 0 .../Split/SmallWidth/LightMode.golden | 0 .../CustomContextLines/DarkMode.golden | 0 .../CustomContextLines/LightMode.golden | 0 .../Unified/Default/DarkMode.golden | 0 .../Unified/Default/LightMode.golden | 0 .../Unified/LargeWidth/DarkMode.golden | 0 .../Unified/LargeWidth/LightMode.golden | 0 .../Unified/MultipleHunks/DarkMode.golden | 0 .../Unified/MultipleHunks/LightMode.golden | 0 .../Unified/Narrow/DarkMode.golden | 0 .../Unified/Narrow/LightMode.golden | 0 .../Unified/NoLineNumbers/DarkMode.golden | 0 .../Unified/NoLineNumbers/LightMode.golden | 0 .../Unified/NoSyntaxHighlight/DarkMode.golden | 0 .../NoSyntaxHighlight/LightMode.golden | 0 .../Unified/SmallWidth/DarkMode.golden | 0 .../Unified/SmallWidth/LightMode.golden | 0 .../Split/HeightOf001.golden | 0 .../Split/HeightOf002.golden | 0 .../Split/HeightOf003.golden | 0 .../Split/HeightOf004.golden | 0 .../Split/HeightOf005.golden | 0 .../Split/HeightOf006.golden | 0 .../Split/HeightOf007.golden | 0 .../Split/HeightOf008.golden | 0 .../Split/HeightOf009.golden | 0 .../Split/HeightOf010.golden | 0 .../Split/HeightOf011.golden | 0 .../Split/HeightOf012.golden | 0 .../Split/HeightOf013.golden | 0 .../Split/HeightOf014.golden | 0 .../Split/HeightOf015.golden | 0 .../Split/HeightOf016.golden | 0 .../Split/HeightOf017.golden | 0 .../Split/HeightOf018.golden | 0 .../Split/HeightOf019.golden | 0 .../Split/HeightOf020.golden | 0 .../Unified/HeightOf001.golden | 0 .../Unified/HeightOf002.golden | 0 .../Unified/HeightOf003.golden | 0 .../Unified/HeightOf004.golden | 0 .../Unified/HeightOf005.golden | 0 .../Unified/HeightOf006.golden | 0 .../Unified/HeightOf007.golden | 0 .../Unified/HeightOf008.golden | 0 .../Unified/HeightOf009.golden | 0 .../Unified/HeightOf010.golden | 0 .../Unified/HeightOf011.golden | 0 .../Unified/HeightOf012.golden | 0 .../Unified/HeightOf013.golden | 0 .../Unified/HeightOf014.golden | 0 .../Unified/HeightOf015.golden | 0 .../Unified/HeightOf016.golden | 0 .../Unified/HeightOf017.golden | 0 .../Unified/HeightOf018.golden | 0 .../Unified/HeightOf019.golden | 0 .../Unified/HeightOf020.golden | 0 .../TestDiffViewLineBreakIssue/Split.golden | 0 .../TestDiffViewLineBreakIssue/Unified.golden | 0 .../testdata/TestDiffViewTabs/Split.golden | 0 .../testdata/TestDiffViewTabs/Unified.golden | 0 .../TestDiffViewWidth/Split/WidthOf001.golden | 0 .../TestDiffViewWidth/Split/WidthOf002.golden | 0 .../TestDiffViewWidth/Split/WidthOf003.golden | 0 .../TestDiffViewWidth/Split/WidthOf004.golden | 0 .../TestDiffViewWidth/Split/WidthOf005.golden | 0 .../TestDiffViewWidth/Split/WidthOf006.golden | 0 .../TestDiffViewWidth/Split/WidthOf007.golden | 0 .../TestDiffViewWidth/Split/WidthOf008.golden | 0 .../TestDiffViewWidth/Split/WidthOf009.golden | 0 .../TestDiffViewWidth/Split/WidthOf010.golden | 0 .../TestDiffViewWidth/Split/WidthOf011.golden | 0 .../TestDiffViewWidth/Split/WidthOf012.golden | 0 .../TestDiffViewWidth/Split/WidthOf013.golden | 0 .../TestDiffViewWidth/Split/WidthOf014.golden | 0 .../TestDiffViewWidth/Split/WidthOf015.golden | 0 .../TestDiffViewWidth/Split/WidthOf016.golden | 0 .../TestDiffViewWidth/Split/WidthOf017.golden | 0 .../TestDiffViewWidth/Split/WidthOf018.golden | 0 .../TestDiffViewWidth/Split/WidthOf019.golden | 0 .../TestDiffViewWidth/Split/WidthOf020.golden | 0 .../TestDiffViewWidth/Split/WidthOf021.golden | 0 .../TestDiffViewWidth/Split/WidthOf022.golden | 0 .../TestDiffViewWidth/Split/WidthOf023.golden | 0 .../TestDiffViewWidth/Split/WidthOf024.golden | 0 .../TestDiffViewWidth/Split/WidthOf025.golden | 0 .../TestDiffViewWidth/Split/WidthOf026.golden | 0 .../TestDiffViewWidth/Split/WidthOf027.golden | 0 .../TestDiffViewWidth/Split/WidthOf028.golden | 0 .../TestDiffViewWidth/Split/WidthOf029.golden | 0 .../TestDiffViewWidth/Split/WidthOf030.golden | 0 .../TestDiffViewWidth/Split/WidthOf031.golden | 0 .../TestDiffViewWidth/Split/WidthOf032.golden | 0 .../TestDiffViewWidth/Split/WidthOf033.golden | 0 .../TestDiffViewWidth/Split/WidthOf034.golden | 0 .../TestDiffViewWidth/Split/WidthOf035.golden | 0 .../TestDiffViewWidth/Split/WidthOf036.golden | 0 .../TestDiffViewWidth/Split/WidthOf037.golden | 0 .../TestDiffViewWidth/Split/WidthOf038.golden | 0 .../TestDiffViewWidth/Split/WidthOf039.golden | 0 .../TestDiffViewWidth/Split/WidthOf040.golden | 0 .../TestDiffViewWidth/Split/WidthOf041.golden | 0 .../TestDiffViewWidth/Split/WidthOf042.golden | 0 .../TestDiffViewWidth/Split/WidthOf043.golden | 0 .../TestDiffViewWidth/Split/WidthOf044.golden | 0 .../TestDiffViewWidth/Split/WidthOf045.golden | 0 .../TestDiffViewWidth/Split/WidthOf046.golden | 0 .../TestDiffViewWidth/Split/WidthOf047.golden | 0 .../TestDiffViewWidth/Split/WidthOf048.golden | 0 .../TestDiffViewWidth/Split/WidthOf049.golden | 0 .../TestDiffViewWidth/Split/WidthOf050.golden | 0 .../TestDiffViewWidth/Split/WidthOf051.golden | 0 .../TestDiffViewWidth/Split/WidthOf052.golden | 0 .../TestDiffViewWidth/Split/WidthOf053.golden | 0 .../TestDiffViewWidth/Split/WidthOf054.golden | 0 .../TestDiffViewWidth/Split/WidthOf055.golden | 0 .../TestDiffViewWidth/Split/WidthOf056.golden | 0 .../TestDiffViewWidth/Split/WidthOf057.golden | 0 .../TestDiffViewWidth/Split/WidthOf058.golden | 0 .../TestDiffViewWidth/Split/WidthOf059.golden | 0 .../TestDiffViewWidth/Split/WidthOf060.golden | 0 .../TestDiffViewWidth/Split/WidthOf061.golden | 0 .../TestDiffViewWidth/Split/WidthOf062.golden | 0 .../TestDiffViewWidth/Split/WidthOf063.golden | 0 .../TestDiffViewWidth/Split/WidthOf064.golden | 0 .../TestDiffViewWidth/Split/WidthOf065.golden | 0 .../TestDiffViewWidth/Split/WidthOf066.golden | 0 .../TestDiffViewWidth/Split/WidthOf067.golden | 0 .../TestDiffViewWidth/Split/WidthOf068.golden | 0 .../TestDiffViewWidth/Split/WidthOf069.golden | 0 .../TestDiffViewWidth/Split/WidthOf070.golden | 0 .../TestDiffViewWidth/Split/WidthOf071.golden | 0 .../TestDiffViewWidth/Split/WidthOf072.golden | 0 .../TestDiffViewWidth/Split/WidthOf073.golden | 0 .../TestDiffViewWidth/Split/WidthOf074.golden | 0 .../TestDiffViewWidth/Split/WidthOf075.golden | 0 .../TestDiffViewWidth/Split/WidthOf076.golden | 0 .../TestDiffViewWidth/Split/WidthOf077.golden | 0 .../TestDiffViewWidth/Split/WidthOf078.golden | 0 .../TestDiffViewWidth/Split/WidthOf079.golden | 0 .../TestDiffViewWidth/Split/WidthOf080.golden | 0 .../TestDiffViewWidth/Split/WidthOf081.golden | 0 .../TestDiffViewWidth/Split/WidthOf082.golden | 0 .../TestDiffViewWidth/Split/WidthOf083.golden | 0 .../TestDiffViewWidth/Split/WidthOf084.golden | 0 .../TestDiffViewWidth/Split/WidthOf085.golden | 0 .../TestDiffViewWidth/Split/WidthOf086.golden | 0 .../TestDiffViewWidth/Split/WidthOf087.golden | 0 .../TestDiffViewWidth/Split/WidthOf088.golden | 0 .../TestDiffViewWidth/Split/WidthOf089.golden | 0 .../TestDiffViewWidth/Split/WidthOf090.golden | 0 .../TestDiffViewWidth/Split/WidthOf091.golden | 0 .../TestDiffViewWidth/Split/WidthOf092.golden | 0 .../TestDiffViewWidth/Split/WidthOf093.golden | 0 .../TestDiffViewWidth/Split/WidthOf094.golden | 0 .../TestDiffViewWidth/Split/WidthOf095.golden | 0 .../TestDiffViewWidth/Split/WidthOf096.golden | 0 .../TestDiffViewWidth/Split/WidthOf097.golden | 0 .../TestDiffViewWidth/Split/WidthOf098.golden | 0 .../TestDiffViewWidth/Split/WidthOf099.golden | 0 .../TestDiffViewWidth/Split/WidthOf100.golden | 0 .../TestDiffViewWidth/Split/WidthOf101.golden | 0 .../TestDiffViewWidth/Split/WidthOf102.golden | 0 .../TestDiffViewWidth/Split/WidthOf103.golden | 0 .../TestDiffViewWidth/Split/WidthOf104.golden | 0 .../TestDiffViewWidth/Split/WidthOf105.golden | 0 .../TestDiffViewWidth/Split/WidthOf106.golden | 0 .../TestDiffViewWidth/Split/WidthOf107.golden | 0 .../TestDiffViewWidth/Split/WidthOf108.golden | 0 .../TestDiffViewWidth/Split/WidthOf109.golden | 0 .../TestDiffViewWidth/Split/WidthOf110.golden | 0 .../Unified/WidthOf001.golden | 0 .../Unified/WidthOf002.golden | 0 .../Unified/WidthOf003.golden | 0 .../Unified/WidthOf004.golden | 0 .../Unified/WidthOf005.golden | 0 .../Unified/WidthOf006.golden | 0 .../Unified/WidthOf007.golden | 0 .../Unified/WidthOf008.golden | 0 .../Unified/WidthOf009.golden | 0 .../Unified/WidthOf010.golden | 0 .../Unified/WidthOf011.golden | 0 .../Unified/WidthOf012.golden | 0 .../Unified/WidthOf013.golden | 0 .../Unified/WidthOf014.golden | 0 .../Unified/WidthOf015.golden | 0 .../Unified/WidthOf016.golden | 0 .../Unified/WidthOf017.golden | 0 .../Unified/WidthOf018.golden | 0 .../Unified/WidthOf019.golden | 0 .../Unified/WidthOf020.golden | 0 .../Unified/WidthOf021.golden | 0 .../Unified/WidthOf022.golden | 0 .../Unified/WidthOf023.golden | 0 .../Unified/WidthOf024.golden | 0 .../Unified/WidthOf025.golden | 0 .../Unified/WidthOf026.golden | 0 .../Unified/WidthOf027.golden | 0 .../Unified/WidthOf028.golden | 0 .../Unified/WidthOf029.golden | 0 .../Unified/WidthOf030.golden | 0 .../Unified/WidthOf031.golden | 0 .../Unified/WidthOf032.golden | 0 .../Unified/WidthOf033.golden | 0 .../Unified/WidthOf034.golden | 0 .../Unified/WidthOf035.golden | 0 .../Unified/WidthOf036.golden | 0 .../Unified/WidthOf037.golden | 0 .../Unified/WidthOf038.golden | 0 .../Unified/WidthOf039.golden | 0 .../Unified/WidthOf040.golden | 0 .../Unified/WidthOf041.golden | 0 .../Unified/WidthOf042.golden | 0 .../Unified/WidthOf043.golden | 0 .../Unified/WidthOf044.golden | 0 .../Unified/WidthOf045.golden | 0 .../Unified/WidthOf046.golden | 0 .../Unified/WidthOf047.golden | 0 .../Unified/WidthOf048.golden | 0 .../Unified/WidthOf049.golden | 0 .../Unified/WidthOf050.golden | 0 .../Unified/WidthOf051.golden | 0 .../Unified/WidthOf052.golden | 0 .../Unified/WidthOf053.golden | 0 .../Unified/WidthOf054.golden | 0 .../Unified/WidthOf055.golden | 0 .../Unified/WidthOf056.golden | 0 .../Unified/WidthOf057.golden | 0 .../Unified/WidthOf058.golden | 0 .../Unified/WidthOf059.golden | 0 .../Unified/WidthOf060.golden | 0 .../Split/XOffsetOf00.golden | 0 .../Split/XOffsetOf01.golden | 0 .../Split/XOffsetOf02.golden | 0 .../Split/XOffsetOf03.golden | 0 .../Split/XOffsetOf04.golden | 0 .../Split/XOffsetOf05.golden | 0 .../Split/XOffsetOf06.golden | 0 .../Split/XOffsetOf07.golden | 0 .../Split/XOffsetOf08.golden | 0 .../Split/XOffsetOf09.golden | 0 .../Split/XOffsetOf10.golden | 0 .../Split/XOffsetOf11.golden | 0 .../Split/XOffsetOf12.golden | 0 .../Split/XOffsetOf13.golden | 0 .../Split/XOffsetOf14.golden | 0 .../Split/XOffsetOf15.golden | 0 .../Split/XOffsetOf16.golden | 0 .../Split/XOffsetOf17.golden | 0 .../Split/XOffsetOf18.golden | 0 .../Split/XOffsetOf19.golden | 0 .../Split/XOffsetOf20.golden | 0 .../Unified/XOffsetOf00.golden | 0 .../Unified/XOffsetOf01.golden | 0 .../Unified/XOffsetOf02.golden | 0 .../Unified/XOffsetOf03.golden | 0 .../Unified/XOffsetOf04.golden | 0 .../Unified/XOffsetOf05.golden | 0 .../Unified/XOffsetOf06.golden | 0 .../Unified/XOffsetOf07.golden | 0 .../Unified/XOffsetOf08.golden | 0 .../Unified/XOffsetOf09.golden | 0 .../Unified/XOffsetOf10.golden | 0 .../Unified/XOffsetOf11.golden | 0 .../Unified/XOffsetOf12.golden | 0 .../Unified/XOffsetOf13.golden | 0 .../Unified/XOffsetOf14.golden | 0 .../Unified/XOffsetOf15.golden | 0 .../Unified/XOffsetOf16.golden | 0 .../Unified/XOffsetOf17.golden | 0 .../Unified/XOffsetOf18.golden | 0 .../Unified/XOffsetOf19.golden | 0 .../Unified/XOffsetOf20.golden | 0 .../Split/YOffsetOf00.golden | 0 .../Split/YOffsetOf01.golden | 0 .../Split/YOffsetOf02.golden | 0 .../Split/YOffsetOf03.golden | 0 .../Split/YOffsetOf04.golden | 0 .../Split/YOffsetOf05.golden | 0 .../Split/YOffsetOf06.golden | 0 .../Split/YOffsetOf07.golden | 0 .../Split/YOffsetOf08.golden | 0 .../Split/YOffsetOf09.golden | 0 .../Split/YOffsetOf10.golden | 0 .../Split/YOffsetOf11.golden | 0 .../Split/YOffsetOf12.golden | 0 .../Split/YOffsetOf13.golden | 0 .../Split/YOffsetOf14.golden | 0 .../Split/YOffsetOf15.golden | 0 .../Split/YOffsetOf16.golden | 0 .../Unified/YOffsetOf00.golden | 0 .../Unified/YOffsetOf01.golden | 0 .../Unified/YOffsetOf02.golden | 0 .../Unified/YOffsetOf03.golden | 0 .../Unified/YOffsetOf04.golden | 0 .../Unified/YOffsetOf05.golden | 0 .../Unified/YOffsetOf06.golden | 0 .../Unified/YOffsetOf07.golden | 0 .../Unified/YOffsetOf08.golden | 0 .../Unified/YOffsetOf09.golden | 0 .../Unified/YOffsetOf10.golden | 0 .../Unified/YOffsetOf11.golden | 0 .../Unified/YOffsetOf12.golden | 0 .../Unified/YOffsetOf13.golden | 0 .../Unified/YOffsetOf14.golden | 0 .../Unified/YOffsetOf15.golden | 0 .../Unified/YOffsetOf16.golden | 0 .../Split/YOffsetOf00.golden | 0 .../Split/YOffsetOf01.golden | 0 .../Split/YOffsetOf02.golden | 0 .../Split/YOffsetOf03.golden | 0 .../Split/YOffsetOf04.golden | 0 .../Split/YOffsetOf05.golden | 0 .../Split/YOffsetOf06.golden | 0 .../Split/YOffsetOf07.golden | 0 .../Split/YOffsetOf08.golden | 0 .../Split/YOffsetOf09.golden | 0 .../Split/YOffsetOf10.golden | 0 .../Split/YOffsetOf11.golden | 0 .../Split/YOffsetOf12.golden | 0 .../Split/YOffsetOf13.golden | 0 .../Split/YOffsetOf14.golden | 0 .../Split/YOffsetOf15.golden | 0 .../Split/YOffsetOf16.golden | 0 .../Unified/YOffsetOf00.golden | 0 .../Unified/YOffsetOf01.golden | 0 .../Unified/YOffsetOf02.golden | 0 .../Unified/YOffsetOf03.golden | 0 .../Unified/YOffsetOf04.golden | 0 .../Unified/YOffsetOf05.golden | 0 .../Unified/YOffsetOf06.golden | 0 .../Unified/YOffsetOf07.golden | 0 .../Unified/YOffsetOf08.golden | 0 .../Unified/YOffsetOf09.golden | 0 .../Unified/YOffsetOf10.golden | 0 .../Unified/YOffsetOf11.golden | 0 .../Unified/YOffsetOf12.golden | 0 .../Unified/YOffsetOf13.golden | 0 .../Unified/YOffsetOf14.golden | 0 .../Unified/YOffsetOf15.golden | 0 .../Unified/YOffsetOf16.golden | 0 .../testdata/TestLineBreakIssue.after | 0 .../testdata/TestLineBreakIssue.before | 0 .../diffview/testdata/TestMultipleHunks.after | 0 .../testdata/TestMultipleHunks.before | 0 .../diffview/testdata/TestNarrow.after | 0 .../diffview/testdata/TestNarrow.before | 0 .../diffview/testdata/TestTabs.after | 0 .../diffview/testdata/TestTabs.before | 0 .../DefaultContextLines/Content.golden | 0 .../DefaultContextLines/JSON.golden | 0 .../DefaultContextLinesPlusOne/Content.golden | 0 .../DefaultContextLinesPlusOne/JSON.golden | 0 .../DefaultContextLinesPlusTwo/Content.golden | 0 .../DefaultContextLinesPlusTwo/JSON.golden | 0 .../testdata/TestUdiff/Unified.golden | 0 .../{tui/exp => ui}/diffview/udiff_test.go | 0 internal/{tui/exp => ui}/diffview/util.go | 0 .../{tui/exp => 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 +- .../{uiutil/uiutil.go => ui/util/util.go} | 6 +- internal/uicmd/uicmd.go | 314 --- 518 files changed, 144 insertions(+), 22039 deletions(-) delete mode 100644 internal/cmd/root_test.go delete mode 100644 internal/tui/components/anim/anim.go delete mode 100644 internal/tui/components/chat/chat.go delete mode 100644 internal/tui/components/chat/editor/clipboard.go delete mode 100644 internal/tui/components/chat/editor/clipboard_not_supported.go delete mode 100644 internal/tui/components/chat/editor/clipboard_supported.go delete mode 100644 internal/tui/components/chat/editor/editor.go delete mode 100644 internal/tui/components/chat/editor/keys.go delete mode 100644 internal/tui/components/chat/header/header.go delete mode 100644 internal/tui/components/chat/messages/messages.go delete mode 100644 internal/tui/components/chat/messages/renderer.go delete mode 100644 internal/tui/components/chat/messages/tool.go delete mode 100644 internal/tui/components/chat/sidebar/sidebar.go delete mode 100644 internal/tui/components/chat/splash/keys.go delete mode 100644 internal/tui/components/chat/splash/splash.go delete mode 100644 internal/tui/components/chat/todos/todos.go delete mode 100644 internal/tui/components/completions/completions.go delete mode 100644 internal/tui/components/completions/keys.go delete mode 100644 internal/tui/components/core/core.go delete mode 100644 internal/tui/components/core/layout/layout.go delete mode 100644 internal/tui/components/core/status/status.go delete mode 100644 internal/tui/components/core/status_test.go delete mode 100644 internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/Default.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/LongDescription.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/NoIcon.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/WithColors.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden delete mode 100644 internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden delete mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden delete mode 100644 internal/tui/components/dialogs/commands/arguments.go delete mode 100644 internal/tui/components/dialogs/commands/commands.go delete mode 100644 internal/tui/components/dialogs/commands/keys.go delete mode 100644 internal/tui/components/dialogs/copilot/device_flow.go delete mode 100644 internal/tui/components/dialogs/dialogs.go delete mode 100644 internal/tui/components/dialogs/filepicker/filepicker.go delete mode 100644 internal/tui/components/dialogs/filepicker/keys.go delete mode 100644 internal/tui/components/dialogs/hyper/device_flow.go delete mode 100644 internal/tui/components/dialogs/keys.go delete mode 100644 internal/tui/components/dialogs/models/apikey.go delete mode 100644 internal/tui/components/dialogs/models/keys.go delete mode 100644 internal/tui/components/dialogs/models/list.go delete mode 100644 internal/tui/components/dialogs/models/list_recent_test.go delete mode 100644 internal/tui/components/dialogs/models/models.go delete mode 100644 internal/tui/components/dialogs/permissions/keys.go delete mode 100644 internal/tui/components/dialogs/permissions/permissions.go delete mode 100644 internal/tui/components/dialogs/quit/keys.go delete mode 100644 internal/tui/components/dialogs/quit/quit.go delete mode 100644 internal/tui/components/dialogs/reasoning/reasoning.go delete mode 100644 internal/tui/components/dialogs/sessions/keys.go delete mode 100644 internal/tui/components/dialogs/sessions/sessions.go delete mode 100644 internal/tui/components/files/files.go delete mode 100644 internal/tui/components/image/image.go delete mode 100644 internal/tui/components/image/load.go delete mode 100644 internal/tui/components/logo/logo.go delete mode 100644 internal/tui/components/logo/rand.go delete mode 100644 internal/tui/components/lsp/lsp.go delete mode 100644 internal/tui/components/mcp/mcp.go delete mode 100644 internal/tui/exp/list/filterable.go delete mode 100644 internal/tui/exp/list/filterable_group.go delete mode 100644 internal/tui/exp/list/filterable_test.go delete mode 100644 internal/tui/exp/list/grouped.go delete mode 100644 internal/tui/exp/list/items.go delete mode 100644 internal/tui/exp/list/keys.go delete mode 100644 internal/tui/exp/list/list.go delete mode 100644 internal/tui/exp/list/list_test.go delete mode 100644 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden delete mode 100644 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 delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 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 delete mode 100644 internal/tui/highlight/highlight.go delete mode 100644 internal/tui/keys.go delete mode 100644 internal/tui/page/chat/chat.go delete mode 100644 internal/tui/page/chat/keys.go delete mode 100644 internal/tui/page/chat/pills.go delete mode 100644 internal/tui/page/page.go delete mode 100644 internal/tui/styles/charmtone.go delete mode 100644 internal/tui/styles/chroma.go delete mode 100644 internal/tui/styles/icons.go delete mode 100644 internal/tui/styles/markdown.go delete mode 100644 internal/tui/styles/theme.go delete mode 100644 internal/tui/tui.go delete mode 100644 internal/tui/util/shell.go delete mode 100644 internal/tui/util/util.go rename internal/{tui/exp => ui}/diffview/Taskfile.yaml (100%) rename internal/{tui/exp => ui}/diffview/chroma.go (100%) rename internal/{tui/exp => ui}/diffview/diffview.go (100%) rename internal/{tui/exp => ui}/diffview/diffview_test.go (99%) rename internal/{tui/exp => ui}/diffview/split.go (100%) rename internal/{tui/exp => ui}/diffview/style.go (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDefault.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDefault.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/Default/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewTabs/Split.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewTabs/Unified.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestLineBreakIssue.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestLineBreakIssue.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestMultipleHunks.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestMultipleHunks.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestNarrow.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestNarrow.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestTabs.after (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestTabs.before (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden (100%) rename internal/{tui/exp => ui}/diffview/testdata/TestUdiff/Unified.golden (100%) rename internal/{tui/exp => ui}/diffview/udiff_test.go (100%) rename internal/{tui/exp => ui}/diffview/util.go (100%) rename internal/{tui/exp => ui}/diffview/util_test.go (100%) create mode 100644 internal/ui/model/filter.go rename internal/{uiutil/uiutil.go => ui/util/util.go} (90%) delete mode 100644 internal/uicmd/uicmd.go diff --git a/go.mod b/go.mod index 2358911b7f6c3633b82b14e589c5db14c02d15d6..34af0a4796c028995a22639695f1c3d03baf6059 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 91d0707fd0a5d50c4d64a8c68b606747b743f4c0..3d63891c57c31aa01ccaeacbaf55af1ab79fe45d 100644 --- a/go.sum +++ b/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= diff --git a/internal/app/app.go b/internal/app/app.go index 219b66f3cb79abcb6f004d08a6dc07bd539198ec..f0cabfa534a58401280fb5e9b973aa6f5a9d91c9 100644 --- a/internal/app/app.go +++ b/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 diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 727e4741dbfc607161e425c6b597ed7e28723a1b..c6dac2e7801779e359c939e7d595323a8ac22e49 100644 --- a/internal/cmd/root.go +++ b/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...) -} diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go deleted file mode 100644 index 8b92f04c4ab7b120985505716e6200cd1845d295..0000000000000000000000000000000000000000 --- a/internal/cmd/root_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/internal/format/spinner.go b/internal/format/spinner.go index 53d48dbb2831df8b6145f762884ee506c2f4ce0a..0a66ce8d886065aecada4999cabafea2f051a2b4 100644 --- a/internal/format/spinner.go +++ b/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 diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go deleted file mode 100644 index 1ffa8074b09afb201a4238c848f3d289450173ce..0000000000000000000000000000000000000000 --- a/internal/tui/components/anim/anim.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go deleted file mode 100644 index 036c8262d2b0d8419bf89b64afd922767b6be12a..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/chat.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/chat/editor/clipboard.go b/internal/tui/components/chat/editor/clipboard.go deleted file mode 100644 index de4b95da3cab6069bf31f61b5fb9e2908f970c07..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard.go +++ /dev/null @@ -1,8 +0,0 @@ -package editor - -type clipboardFormat int - -const ( - clipboardFormatText clipboardFormat = iota - clipboardFormatImage -) diff --git a/internal/tui/components/chat/editor/clipboard_not_supported.go b/internal/tui/components/chat/editor/clipboard_not_supported.go deleted file mode 100644 index dfecc09dca05ca5d07dd1db109fe3178f6c357b8..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard_not_supported.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !(darwin || linux || windows) || arm || 386 || ios || android - -package editor - -func readClipboard(clipboardFormat) ([]byte, error) { - return nil, errClipboardPlatformUnsupported -} diff --git a/internal/tui/components/chat/editor/clipboard_supported.go b/internal/tui/components/chat/editor/clipboard_supported.go deleted file mode 100644 index 175a4b4ea4dfaea03916dc1012c313201f1846f8..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/clipboard_supported.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go deleted file mode 100644 index 575c23114a9115209db7a2a02e642fe5f2246541..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/editor.go +++ /dev/null @@ -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]) -} diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go deleted file mode 100644 index c20df5cc1c071deab83754430543b9be2381127c..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor/keys.go +++ /dev/null @@ -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"), - ), -} diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go deleted file mode 100644 index c8848440b1193fda9a7b5df4b31e03edeaf744c4..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/header/header.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go deleted file mode 100644 index 3c91f9f41485b439b8c25ca0692c7265ccafb14a..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/messages.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go deleted file mode 100644 index 5fbd8a653c0b0374029bf13b31721d8ad5150948..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/renderer.go +++ /dev/null @@ -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 ¶mBuilder{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 ": 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms) - - 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, ¶ms); 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 - }) -} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go deleted file mode 100644 index b8163f5a4c2a51f13ebd7ba2650bb7c3f33dac44..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/messages/tool.go +++ /dev/null @@ -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), ¶ms) == 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), ¶ms) == 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), ¶ms) == nil { - return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) - } - case tools.MultiEditToolName: - var params tools.MultiEditParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == 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), ¶ms) == nil { - return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) - } - case tools.FetchToolName: - var params tools.FetchParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == 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), ¶ms) == 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), ¶ms) == nil { - return fmt.Sprintf("**URL:** %s", params.URL) - } - case tools.GrepToolName: - var params tools.GrepParams - if json.Unmarshal([]byte(m.call.Input), ¶ms) == 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), ¶ms) == 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), ¶ms) == 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), ¶ms) == 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), ¶ms) == 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), ¶ms) == nil { - return fmt.Sprintf("**Task:**\n%s", params.Prompt) - } - } - - var params map[string]any - if json.Unmarshal([]byte(m.call.Input), ¶ms) == 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), ¶ms) - - 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), ¶ms) - - 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), ¶ms) != 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), ¶ms) != 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), ¶ms) != 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), ¶ms) != 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 -} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go deleted file mode 100644 index 40bc8821e0a3dc7c3dec62bbcde34a5241ec4aa7..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ /dev/null @@ -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)) -} diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go deleted file mode 100644 index fc8fc373498feea584e75701010762ac66db7879..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/splash/keys.go +++ /dev/null @@ -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"), - ), - } -} diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go deleted file mode 100644 index 886fe5e530978678246ab120b21e0f943018fd1a..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/splash/splash.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/chat/todos/todos.go b/internal/tui/components/chat/todos/todos.go deleted file mode 100644 index 8973e4f4675df65c5ff7466665ddf18e74d2203e..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/todos/todos.go +++ /dev/null @@ -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") -} diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go deleted file mode 100644 index 31532952f6243a466c18d55230875346448c151a..0000000000000000000000000000000000000000 --- a/internal/tui/components/completions/completions.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go deleted file mode 100644 index 7adaaa02195e5266df0ecb3823fa15d918adb4ab..0000000000000000000000000000000000000000 --- a/internal/tui/components/completions/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/core/core.go b/internal/tui/components/core/core.go deleted file mode 100644 index 2b60664c26a6082fafd28626d471575b706c9890..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/core.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go deleted file mode 100644 index 99358755d6070286aab00ac13aeb3d3da2b91e3d..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/layout.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go deleted file mode 100644 index 6d14c5db4c5c343e16bbda7f0846a0fcbfa61b36..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status/status.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/core/status_test.go b/internal/tui/components/core/status_test.go deleted file mode 100644 index c82fc5b2a3e735e1eafd385b74ae5a4877032bd9..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status_test.go +++ /dev/null @@ -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)) - }) - } -} diff --git a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden deleted file mode 100644 index 89477e3738e6547ea26734e8a49df5d281d70c57..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden +++ /dev/null @@ -1 +0,0 @@ -🚀 Deployment Deploying to production environment v1.2.3 \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/Default.golden b/internal/tui/components/core/testdata/TestStatus/Default.golden deleted file mode 100644 index 2151efd10b7aeb6500b55a0e61fbf5d4a6ef1638..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/Default.golden +++ /dev/null @@ -1 +0,0 @@ -Status Everything is working fine \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden b/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden deleted file mode 100644 index db4acad54383ecbc2cc50061ee5ba77491dc545d..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden +++ /dev/null @@ -1 +0,0 @@ -● Title Only \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden b/internal/tui/components/core/testdata/TestStatus/LongDescription.golden deleted file mode 100644 index 13fc6c3335871aaa5513d370d078f8e350571abe..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden +++ /dev/null @@ -1 +0,0 @@ -Processing This is a very long description that should be … \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden deleted file mode 100644 index 0c5b8e93c35e302038e019d58682716b1b220ef7..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden +++ /dev/null @@ -1 +0,0 @@ -● Status Short message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NoIcon.golden b/internal/tui/components/core/testdata/TestStatus/NoIcon.golden deleted file mode 100644 index 09e14574c853264a4b18dfafcfac256b38045a02..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/NoIcon.golden +++ /dev/null @@ -1 +0,0 @@ -Info This status has no icon \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden deleted file mode 100644 index 9bb3917977486b8f862c74db4f43951a9c44a450..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden +++ /dev/null @@ -1 +0,0 @@ -● Test This will be… \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithColors.golden b/internal/tui/components/core/testdata/TestStatus/WithColors.golden deleted file mode 100644 index 97eeb24db9a9803f4d8877296d38a9d878b50fed..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithColors.golden +++ /dev/null @@ -1 +0,0 @@ -⚠ Warning This is a warning message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden b/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden deleted file mode 100644 index 00cf9455b72e0fd3b8fc94e48b09053bb3fde60a..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden +++ /dev/null @@ -1 +0,0 @@ -✓ Success Operation completed successfully \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden deleted file mode 100644 index 292d1fa97f0400a7c411eff5a658af537fc8b69e..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden +++ /dev/null @@ -1 +0,0 @@ -Build Building project [2/5] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden deleted file mode 100644 index 0df96289f5aa373f174aa9f833478d5c559abe53..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title  [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden deleted file mode 100644 index 56915d1966ab547740910398b101fd70371bb264..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title Thi… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden deleted file mode 100644 index 6b249b2f865698ebc73ed7787daad30ddf417945..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an ex… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden deleted file mode 100644 index 1862198d631f525c3080f7f811ade5a5738658b1..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an extremely lo… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden deleted file mode 100644 index 0f29e46d2660d1bf2584c730c50972e962c4dd32..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden +++ /dev/null @@ -1 +0,0 @@ -● Very Long Title This is an extremely long descrip… [extra] \ No newline at end of file diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go deleted file mode 100644 index 690d29e6c380e46777b57982913132a24c56448f..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/arguments.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go deleted file mode 100644 index 3c86c984561f96350b2b621c15ae14be9649ae36..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/commands.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go deleted file mode 100644 index f07f1c5f4a6db353d6d53888a3bf869702bfb24c..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/dialogs/copilot/device_flow.go b/internal/tui/components/dialogs/copilot/device_flow.go deleted file mode 100644 index d8a2850c3ea151021958a07b350df879d1db4554..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/copilot/device_flow.go +++ /dev/null @@ -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} - } -} diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go deleted file mode 100644 index 4dacd56daa8008b42ebe7ede8bdb6c955b27dbe5..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/dialogs.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go deleted file mode 100644 index fd9f85e70d1a100ec33d89219dd4d276459bb6ee..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/filepicker/filepicker.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go deleted file mode 100644 index 1fc493ba148e9d48f0348b3f3d49a132ffe60da2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/filepicker/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/dialogs/hyper/device_flow.go b/internal/tui/components/dialogs/hyper/device_flow.go deleted file mode 100644 index b88d3aae8a2d1a826d5827c9f4112911602db2a2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/hyper/device_flow.go +++ /dev/null @@ -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} - } -} diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go deleted file mode 100644 index 178ea65612a0db8072f21c0a17335d7c627afae4..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go deleted file mode 100644 index 6ab890ca83bdcce55cc3441683c9b2c6e6acf542..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/apikey.go +++ /dev/null @@ -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() -} diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go deleted file mode 100644 index ff81404b1f1937fff09d917bf3a9e3b24f4d38c9..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go deleted file mode 100644 index 50469a132aab60c3e63a77d9169c47688d5d9151..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/list.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/components/dialogs/models/list_recent_test.go b/internal/tui/components/dialogs/models/list_recent_test.go deleted file mode 100644 index 5afdde98502d3d26d46dce00ab1825ca07f36831..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/list_recent_test.go +++ /dev/null @@ -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") -} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go deleted file mode 100644 index 34f91d060cf7b7a7fd0a3a6fe678a23ed8439530..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/models/models.go +++ /dev/null @@ -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...) -} diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go deleted file mode 100644 index 5e7786ec1eddf1f3491f3a961c087f72911f1c33..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/permissions/keys.go +++ /dev/null @@ -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"), - ), - } -} diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go deleted file mode 100644 index d743d36f5b4674cd09fe7761c005fc2b9979252b..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go deleted file mode 100644 index 15b3e85e0da960a9a63562427f2f2e2f624ab627..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/quit/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go deleted file mode 100644 index 4ffc04a0d1bf2397e2c00c7b321c360d9566d623..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/quit/quit.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/reasoning/reasoning.go b/internal/tui/components/dialogs/reasoning/reasoning.go deleted file mode 100644 index dfe6898b90b516903dc3b6b490641899c8cc6ca2..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/reasoning/reasoning.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go deleted file mode 100644 index 94b260bd71261699413151836c672b2498e03abe..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/sessions/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go deleted file mode 100644 index 11515eeedf8347b8eba5c94b7e0d35715d1380cc..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go deleted file mode 100644 index c7898d472452fd8465394ccea1131a15224712b2..0000000000000000000000000000000000000000 --- a/internal/tui/components/files/files.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/image/image.go b/internal/tui/components/image/image.go deleted file mode 100644 index b526b1bb0a4b1eaf186a55475980bd81f5704ff6..0000000000000000000000000000000000000000 --- a/internal/tui/components/image/image.go +++ /dev/null @@ -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 == "" -} diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go deleted file mode 100644 index 2ca5d4bac77bdd660faf5bd41bdb1e385b4610a0..0000000000000000000000000000000000000000 --- a/internal/tui/components/image/load.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go deleted file mode 100644 index 9f4cdfef36723cc69dd13f4a60dcd76f0c8f9904..0000000000000000000000000000000000000000 --- a/internal/tui/components/logo/logo.go +++ /dev/null @@ -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...) -} diff --git a/internal/tui/components/logo/rand.go b/internal/tui/components/logo/rand.go deleted file mode 100644 index cf79487e23825b468c98a0f27bbc8dbfbb1a7081..0000000000000000000000000000000000000000 --- a/internal/tui/components/logo/rand.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go deleted file mode 100644 index 3379c2c9acfd7e7e10d6e6777e2554d0b0db2144..0000000000000000000000000000000000000000 --- a/internal/tui/components/lsp/lsp.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go deleted file mode 100644 index 78763ac85fdbb5b75e281ef39289f490e6bde949..0000000000000000000000000000000000000000 --- a/internal/tui/components/mcp/mcp.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go deleted file mode 100644 index 8956bfa60dd36cee115cc82ef9ea2adb758219e9..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go deleted file mode 100644 index 8597050cbc3820a53efe467182c8625f608616c2..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable_group.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go deleted file mode 100644 index ce61f2c0f4014c9d16c29675eff7ecbb060b2dfd..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/filterable_test.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go deleted file mode 100644 index b1408aa663a4847ad4acaaf89d8b2282cf2b3aab..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/grouped.go +++ /dev/null @@ -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() -} diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go deleted file mode 100644 index 3db5635b044d9845915d005dd5f7cdac233fe53f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/items.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/exp/list/keys.go b/internal/tui/exp/list/keys.go deleted file mode 100644 index e470fbfbea2ea9f958949ebdfabe5fd679192f9c..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go deleted file mode 100644 index 653dcd7b2389d50ec19b4dc0f005f7a423e14012..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/list.go +++ /dev/null @@ -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() -} diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go deleted file mode 100644 index 57ca7883f87e9facf82b46f60f66f2101a08428a..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/list_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden deleted file mode 100644 index 01668d35b2d07b73b1daf709578d1dccf72a4cea..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden +++ /dev/null @@ -1,10 +0,0 @@ -> Type to filter  -│Item 0  -Item 1  -Item 2  -Item 3  -Item 4  - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden deleted file mode 100644 index 7775902a7b151f55d9182fe2af00bd1a0f8e261b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden deleted file mode 100644 index 7775902a7b151f55d9182fe2af00bd1a0f8e261b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden deleted file mode 100644 index 4eb402d4d275af1e95c28c538b0059f75fd15a88..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden deleted file mode 100644 index f167f64ffd978440b6df4f59911c384ed0538a66..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 -Item 3 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index d54f38ec7432b9f24930015a7415aa3604b97025..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden deleted file mode 100644 index aaa3c01a3e5cec4da20bdb25af8bc9c86d8ccfd5..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden deleted file mode 100644 index a11b23ef049201e56929376a6638bd12718b7a3f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden +++ /dev/null @@ -1,20 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden deleted file mode 100644 index 55b683ef02e235e03bbe941093d557dd06dfd888..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden +++ /dev/null @@ -1,20 +0,0 @@ -Item 0 -Item 1 -Item 2 -Item 3 -│Item 4 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden deleted file mode 100644 index d304f35cc7594d9070555ff914980787b7cfb987..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 6 -Item 6 -Item 6 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden deleted file mode 100644 index 65c98367d817411de97cfae7a34737efe0217d6b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -│Item 3 -│Item 3 -│Item 3 -│Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden deleted file mode 100644 index 03582cc911ee2f3d50e428cd320c25a13c99147b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 28 -│Item 28 -│Item 28 -│Item 28 -│Item 28 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden deleted file mode 100644 index d54f38ec7432b9f24930015a7415aa3604b97025..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 8cea66d71fb8e43fc9e0ac8fcb6ee1000cfcb5e4..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -│Testing  \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index faed253a104304630e9e33decc445622cde8739a..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -│Testing  -Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 4eb402d4d275af1e95c28c538b0059f75fd15a88..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index f377a4fd04f868d775c279849fd65723afaac901..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 -Item 30 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 1a5650ba234a86b20584a146124d7b0c8023679f..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/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 b/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 deleted file mode 100644 index 9ac6e51a8a45f645d7e7f10dc4ea0542155e198e..0000000000000000000000000000000000000000 --- a/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 +++ /dev/null @@ -1,10 +0,0 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file diff --git a/internal/tui/highlight/highlight.go b/internal/tui/highlight/highlight.go deleted file mode 100644 index c8cf833056603d18e6bd7ecac8f27a6652fdfde7..0000000000000000000000000000000000000000 --- a/internal/tui/highlight/highlight.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index bee9a3063ed375819298e01098524f15247ba280..0000000000000000000000000000000000000000 --- a/internal/tui/keys.go +++ /dev/null @@ -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"), - ), - } -} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go deleted file mode 100644 index bb2eb755bf80995dd41d9ac564174de5b90262bb..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/chat.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go deleted file mode 100644 index f22ec2bb4915b3d30e72df7f6f867e88447f5b7b..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/keys.go +++ /dev/null @@ -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"), - ), - } -} diff --git a/internal/tui/page/chat/pills.go b/internal/tui/page/chat/pills.go deleted file mode 100644 index 40a363626946907641ad25bb44a1e9c3df752945..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat/pills.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/page/page.go b/internal/tui/page/page.go deleted file mode 100644 index 482df5fd7b85706fb59a90e9ca5938de8fb729ea..0000000000000000000000000000000000000000 --- a/internal/tui/page/page.go +++ /dev/null @@ -1,8 +0,0 @@ -package page - -type PageID string - -// PageChangeMsg is used to change the current page -type PageChangeMsg struct { - ID PageID -} diff --git a/internal/tui/styles/charmtone.go b/internal/tui/styles/charmtone.go deleted file mode 100644 index 44508e5a24e68ea0507af0f2649ddc372711104d..0000000000000000000000000000000000000000 --- a/internal/tui/styles/charmtone.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/styles/chroma.go b/internal/tui/styles/chroma.go deleted file mode 100644 index bd656336a8236f5f9aa57fc2d7b98a1e84d4c932..0000000000000000000000000000000000000000 --- a/internal/tui/styles/chroma.go +++ /dev/null @@ -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), - } -} diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go deleted file mode 100644 index 0db13358a2f9812293c18497b71ba138484b8f17..0000000000000000000000000000000000000000 --- a/internal/tui/styles/icons.go +++ /dev/null @@ -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, -} diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go deleted file mode 100644 index fd857703ee21912ee3ddc7d6112798317c34fa59..0000000000000000000000000000000000000000 --- a/internal/tui/styles/markdown.go +++ /dev/null @@ -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, - }, - } -} diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go deleted file mode 100644 index b03603c57439f5f950f9860d3287b0f9d13742e5..0000000000000000000000000000000000000000 --- a/internal/tui/styles/theme.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index 9a51a2497f09875d743e1051465dec7c1ac46e67..0000000000000000000000000000000000000000 --- a/internal/tui/tui.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/util/shell.go b/internal/tui/util/shell.go deleted file mode 100644 index 7bf30e2640e79a80291077faa5134a9eea28a87b..0000000000000000000000000000000000000000 --- a/internal/tui/util/shell.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go deleted file mode 100644 index 5df57c11cc4491b25a048c7437057408f1e9c30f..0000000000000000000000000000000000000000 --- a/internal/tui/util/util.go +++ /dev/null @@ -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 -) diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 0c811b0384b0b1bf24b227b6500e8c9a21726d21..6e7c632474389aa5455295e4132818941bc18244 100644 --- a/internal/ui/common/common.go +++ b/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), ) } diff --git a/internal/ui/common/diff.go b/internal/ui/common/diff.go index 8007cebce93a0d0833be779eb11cbb703bc8c1d6..4fdbb3e4c48f23caccd715066d9087fea8654202 100644 --- a/internal/ui/common/diff.go +++ b/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" ) diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 7c11cbd91b202cfc16e1988027f9eed657368620..2776d886d49d979fd7673fb830dfa9d9a11f9006 100644 --- a/internal/ui/dialog/actions.go +++ b/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), } } diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index cb00477d3d5fb0f4aecb9167aa300980862e9132..6e9d26f0c5badc0d96bb6c6212ae3b69856b9e06 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/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{ diff --git a/internal/ui/dialog/arguments.go b/internal/ui/dialog/arguments.go index 172c44eba0e015ee5562507fe92254cb047d4632..96eff11940841e2377e85fafeab9850fb844f139 100644 --- a/internal/ui/dialog/arguments.go +++ b/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 } } diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 44ff42a23c5eb722e4baa764346f631292799b30..2f729e19995790fc1bb57fbea4b80191195df8da 100644 --- a/internal/ui/dialog/models.go +++ b/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 diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 6fbb039255144ad14b15a39f34942e504dea3f2c..93d5fe052db11d036d29d7790810807d5630bb57 100644 --- a/internal/ui/dialog/oauth.go +++ b/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{ diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 227e060e6c6483644b4ad18bef00153bd4f6ca5f..cfb0f30623c383b775c3a960134057e6c79ce9b8 100644 --- a/internal/ui/dialog/sessions.go +++ b/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 } diff --git a/internal/tui/exp/diffview/Taskfile.yaml b/internal/ui/diffview/Taskfile.yaml similarity index 100% rename from internal/tui/exp/diffview/Taskfile.yaml rename to internal/ui/diffview/Taskfile.yaml diff --git a/internal/tui/exp/diffview/chroma.go b/internal/ui/diffview/chroma.go similarity index 100% rename from internal/tui/exp/diffview/chroma.go rename to internal/ui/diffview/chroma.go diff --git a/internal/tui/exp/diffview/diffview.go b/internal/ui/diffview/diffview.go similarity index 100% rename from internal/tui/exp/diffview/diffview.go rename to internal/ui/diffview/diffview.go diff --git a/internal/tui/exp/diffview/diffview_test.go b/internal/ui/diffview/diffview_test.go similarity index 99% rename from internal/tui/exp/diffview/diffview_test.go rename to internal/ui/diffview/diffview_test.go index de6eb301c9261fdbf2175ba061528b71e162ea40..266b26372dc7c519c353228590a03cbbc9e65e24 100644 --- a/internal/tui/exp/diffview/diffview_test.go +++ b/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" ) diff --git a/internal/tui/exp/diffview/split.go b/internal/ui/diffview/split.go similarity index 100% rename from internal/tui/exp/diffview/split.go rename to internal/ui/diffview/split.go diff --git a/internal/tui/exp/diffview/style.go b/internal/ui/diffview/style.go similarity index 100% rename from internal/tui/exp/diffview/style.go rename to internal/ui/diffview/style.go diff --git a/internal/tui/exp/diffview/testdata/TestDefault.after b/internal/ui/diffview/testdata/TestDefault.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDefault.after rename to internal/ui/diffview/testdata/TestDefault.after diff --git a/internal/tui/exp/diffview/testdata/TestDefault.before b/internal/ui/diffview/testdata/TestDefault.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDefault.before rename to internal/ui/diffview/testdata/TestDefault.before diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden b/internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden rename to internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden b/internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden b/internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden rename to internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden b/internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden rename to internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewTabs/Split.golden b/internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewTabs/Split.golden rename to internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewTabs/Unified.golden b/internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewTabs/Unified.golden rename to internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden b/internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden rename to internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden b/internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden rename to internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden diff --git a/internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden b/internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden rename to internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden diff --git a/internal/tui/exp/diffview/testdata/TestLineBreakIssue.after b/internal/ui/diffview/testdata/TestLineBreakIssue.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestLineBreakIssue.after rename to internal/ui/diffview/testdata/TestLineBreakIssue.after diff --git a/internal/tui/exp/diffview/testdata/TestLineBreakIssue.before b/internal/ui/diffview/testdata/TestLineBreakIssue.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestLineBreakIssue.before rename to internal/ui/diffview/testdata/TestLineBreakIssue.before diff --git a/internal/tui/exp/diffview/testdata/TestMultipleHunks.after b/internal/ui/diffview/testdata/TestMultipleHunks.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestMultipleHunks.after rename to internal/ui/diffview/testdata/TestMultipleHunks.after diff --git a/internal/tui/exp/diffview/testdata/TestMultipleHunks.before b/internal/ui/diffview/testdata/TestMultipleHunks.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestMultipleHunks.before rename to internal/ui/diffview/testdata/TestMultipleHunks.before diff --git a/internal/tui/exp/diffview/testdata/TestNarrow.after b/internal/ui/diffview/testdata/TestNarrow.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestNarrow.after rename to internal/ui/diffview/testdata/TestNarrow.after diff --git a/internal/tui/exp/diffview/testdata/TestNarrow.before b/internal/ui/diffview/testdata/TestNarrow.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestNarrow.before rename to internal/ui/diffview/testdata/TestNarrow.before diff --git a/internal/tui/exp/diffview/testdata/TestTabs.after b/internal/ui/diffview/testdata/TestTabs.after similarity index 100% rename from internal/tui/exp/diffview/testdata/TestTabs.after rename to internal/ui/diffview/testdata/TestTabs.after diff --git a/internal/tui/exp/diffview/testdata/TestTabs.before b/internal/ui/diffview/testdata/TestTabs.before similarity index 100% rename from internal/tui/exp/diffview/testdata/TestTabs.before rename to internal/ui/diffview/testdata/TestTabs.before diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden b/internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden rename to internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden diff --git a/internal/tui/exp/diffview/testdata/TestUdiff/Unified.golden b/internal/ui/diffview/testdata/TestUdiff/Unified.golden similarity index 100% rename from internal/tui/exp/diffview/testdata/TestUdiff/Unified.golden rename to internal/ui/diffview/testdata/TestUdiff/Unified.golden diff --git a/internal/tui/exp/diffview/udiff_test.go b/internal/ui/diffview/udiff_test.go similarity index 100% rename from internal/tui/exp/diffview/udiff_test.go rename to internal/ui/diffview/udiff_test.go diff --git a/internal/tui/exp/diffview/util.go b/internal/ui/diffview/util.go similarity index 100% rename from internal/tui/exp/diffview/util.go rename to internal/ui/diffview/util.go diff --git a/internal/tui/exp/diffview/util_test.go b/internal/ui/diffview/util_test.go similarity index 100% rename from internal/tui/exp/diffview/util_test.go rename to internal/ui/diffview/util_test.go diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 07039433dded1647646704959791dfcad7d3d69f..5af0ca7c4776cd45371d2a57e3a13dc6195b524e 100644 --- a/internal/ui/image/image.go +++ b/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", } } diff --git a/internal/ui/logo/logo.go b/internal/ui/logo/logo.go index 9f4cdfef36723cc69dd13f4a60dcd76f0c8f9904..68387d4c0ba2c8914929d041e149f4b23ff3694b 100644 --- a/internal/ui/logo/logo.go +++ b/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 } diff --git a/internal/ui/model/filter.go b/internal/ui/model/filter.go new file mode 100644 index 0000000000000000000000000000000000000000..b28a4d061b2e2adad0d712de014e0ccf610e0485 --- /dev/null +++ b/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 +} diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 1cd481f2f9a3625ba0ed8f12c8450265c0aa5ef0..0a6ec0775b9f21da9bac4ed5ac2a7013457176a1 100644 --- a/internal/ui/model/onboarding.go +++ b/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} } diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index 38fd718db9cf2b44eb48538a9debb25870b90a7d..5b38da8b0d042486b19060047d2c715d514aef82 100644 --- a/internal/ui/model/session.go +++ b/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) diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 7316025aaedad67688b226cf1c7c37314f3b7a30..c3498b964ca5ebbc2446ffc31855a1c225a7ab5e 100644 --- a/internal/ui/model/sidebar.go +++ b/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, diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go index 2e1b9396e32b970c663cb755e2dc74f6c9f5eca0..66dd4082bcc90470129b4a8ebf4ebd65e8567d6c 100644 --- a/internal/ui/model/status.go +++ b/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{} }) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d84d95c516892e8fa9538664dc0a2549dfa09fe1..65d1e720cd91d50c87d57f5409a42595915c1e40 100644 --- a/internal/ui/model/ui.go +++ b/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, diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 45aa6dc998226469f800883fb4ff9452cb56481a..c1d548ec25a2afb0645927a22fa5821ba8c3b968 100644 --- a/internal/ui/styles/styles.go +++ b/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" ) diff --git a/internal/uiutil/uiutil.go b/internal/ui/util/util.go similarity index 90% rename from internal/uiutil/uiutil.go rename to internal/ui/util/util.go index d0443f9c1e4b40fc23b3fb9d597a1d0cd785e1b0..7a53df7d1e4e676b3b142de9ec74deff614c8af2 100644 --- a/internal/uiutil/uiutil.go +++ b/internal/ui/util/util.go @@ -1,7 +1,5 @@ -// Package uiutil provides utility functions for UI message handling. -// TODO: Move to internal/ui/ once the new UI migration -// is finalized. -package uiutil +// Package util provides utility functions for UI message handling. +package util import ( "context" diff --git a/internal/uicmd/uicmd.go b/internal/uicmd/uicmd.go deleted file mode 100644 index c2ce2d89d1457459ac84c9e97c6e68b371e042d8..0000000000000000000000000000000000000000 --- a/internal/uicmd/uicmd.go +++ /dev/null @@ -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 -}