on main help sign in
refactor: remove old tui (#2008)
commit9013bb012202eae93a936273782014f5c6ae35d2 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 5 Feb 2026, 12:02 (8 months ago) parentfd43746
518 files changed+144−22039
go.mod+1−10 go.sum−18 internal/app/app.go+3−3 internal/cmd/root.go+4−36 internal/cmd/root_test.go−160 internal/format/spinner.go+7−6 internal/tui/components/anim/anim.go−447 internal/tui/components/chat/chat.go−782 internal/tui/components/chat/editor/clipboard.go−8 internal/tui/components/chat/editor/clipboard_not_supported.go−7 internal/tui/components/chat/editor/clipboard_supported.go−15 internal/tui/components/chat/editor/editor.go−780 internal/tui/components/chat/editor/keys.go−77 internal/tui/components/chat/header/header.go−160 internal/tui/components/chat/messages/messages.go−461 internal/tui/components/chat/messages/renderer.go−1403 internal/tui/components/chat/messages/tool.go−877 internal/tui/components/chat/sidebar/sidebar.go−608 internal/tui/components/chat/splash/keys.go−58 internal/tui/components/chat/splash/splash.go−874 internal/tui/components/chat/todos/todos.go−67 internal/tui/components/completions/completions.go−308 internal/tui/components/completions/keys.go−72 internal/tui/components/core/core.go−207 internal/tui/components/core/layout/layout.go−27 internal/tui/components/core/status/status.go−113 internal/tui/components/core/status_test.go−144 internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden−1 internal/tui/components/core/testdata/TestStatus/Default.golden−1 internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden−1 internal/tui/components/core/testdata/TestStatus/LongDescription.golden−1 internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden−1 internal/tui/components/core/testdata/TestStatus/NoIcon.golden−1 internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden−1 internal/tui/components/core/testdata/TestStatus/WithColors.golden−1 internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden−1 internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden−1 internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden−1 internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden−1 internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden−1 internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden−1 internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden−1 internal/tui/components/dialogs/commands/arguments.go−245 internal/tui/components/dialogs/commands/commands.go−479 internal/tui/components/dialogs/commands/keys.go−133 internal/tui/components/dialogs/copilot/device_flow.go−281 internal/tui/components/dialogs/dialogs.go−165 internal/tui/components/dialogs/filepicker/filepicker.go−260 internal/tui/components/dialogs/filepicker/keys.go−80 internal/tui/components/dialogs/hyper/device_flow.go−267 internal/tui/components/dialogs/keys.go−43 internal/tui/components/dialogs/models/apikey.go−203 internal/tui/components/dialogs/models/keys.go−120 internal/tui/components/dialogs/models/list.go−333 internal/tui/components/dialogs/models/list_recent_test.go−369 internal/tui/components/dialogs/models/models.go−549 internal/tui/components/dialogs/permissions/keys.go−113 internal/tui/components/dialogs/permissions/permissions.go−899 internal/tui/components/dialogs/quit/keys.go−75 internal/tui/components/dialogs/quit/quit.go−120 internal/tui/components/dialogs/reasoning/reasoning.go−264 internal/tui/components/dialogs/sessions/keys.go−67 internal/tui/components/dialogs/sessions/sessions.go−181 internal/tui/components/files/files.go−146 internal/tui/components/image/image.go−86 internal/tui/components/image/load.go−169 internal/tui/components/logo/logo.go−346 internal/tui/components/logo/rand.go−24 internal/tui/components/lsp/lsp.go−144 internal/tui/components/mcp/mcp.go−138 internal/tui/exp/list/filterable.go−329 internal/tui/exp/list/filterable_group.go−315 internal/tui/exp/list/filterable_test.go−68 internal/tui/exp/list/grouped.go−100 internal/tui/exp/list/items.go−399 internal/tui/exp/list/keys.go−76 internal/tui/exp/list/list.go−1775 internal/tui/exp/list/list_test.go−653 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden−10 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden−10 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden−10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden−10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden−10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden−10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden−10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden−20 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden−20 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden−10 internal/tui/highlight/highlight.go−54 internal/tui/keys.go−45 internal/tui/page/chat/chat.go−1407 internal/tui/page/chat/keys.go−53 internal/tui/page/chat/pills.go−125 internal/tui/page/page.go−8 internal/tui/styles/charmtone.go−83 internal/tui/styles/chroma.go−79 internal/tui/styles/icons.go−48 internal/tui/styles/markdown.go−205 internal/tui/styles/theme.go−709 internal/tui/tui.go−712 internal/tui/util/shell.go−15 internal/tui/util/util.go−45 internal/ui/common/common.go+2−2 internal/ui/common/diff.go+1−1 internal/ui/dialog/actions.go+7−7 internal/ui/dialog/api_key_input.go+2−2 internal/ui/dialog/arguments.go+2−2 internal/ui/dialog/models.go+2−2 internal/ui/dialog/oauth.go+5−5 internal/ui/dialog/sessions.go+4−4 internal/tui/exp/diffview/Taskfile.yaml → internal/ui/diffview/Taskfile.yaml internal/tui/exp/diffview/chroma.go → internal/ui/diffview/chroma.go internal/tui/exp/diffview/diffview.go → internal/ui/diffview/diffview.go internal/tui/exp/diffview/diffview_test.go → internal/ui/diffview/diffview_test.go+1−1 internal/tui/exp/diffview/split.go → internal/ui/diffview/split.go internal/tui/exp/diffview/style.go → internal/ui/diffview/style.go internal/tui/exp/diffview/testdata/TestDefault.after → internal/ui/diffview/testdata/TestDefault.after internal/tui/exp/diffview/testdata/TestDefault.before → internal/ui/diffview/testdata/TestDefault.before internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden → internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden → internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden internal/tui/exp/diffview/testdata/TestDiffViewTabs/Split.golden → internal/ui/diffview/testdata/TestDiffViewTabs/Split.golden internal/tui/exp/diffview/testdata/TestDiffViewTabs/Unified.golden → internal/ui/diffview/testdata/TestDiffViewTabs/Unified.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden internal/tui/exp/diffview/testdata/TestLineBreakIssue.after → internal/ui/diffview/testdata/TestLineBreakIssue.after internal/tui/exp/diffview/testdata/TestLineBreakIssue.before → internal/ui/diffview/testdata/TestLineBreakIssue.before internal/tui/exp/diffview/testdata/TestMultipleHunks.after → internal/ui/diffview/testdata/TestMultipleHunks.after internal/tui/exp/diffview/testdata/TestMultipleHunks.before → internal/ui/diffview/testdata/TestMultipleHunks.before internal/tui/exp/diffview/testdata/TestNarrow.after → internal/ui/diffview/testdata/TestNarrow.after internal/tui/exp/diffview/testdata/TestNarrow.before → internal/ui/diffview/testdata/TestNarrow.before internal/tui/exp/diffview/testdata/TestTabs.after → internal/ui/diffview/testdata/TestTabs.after internal/tui/exp/diffview/testdata/TestTabs.before → internal/ui/diffview/testdata/TestTabs.before internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden internal/tui/exp/diffview/testdata/TestUdiff/Unified.golden → internal/ui/diffview/testdata/TestUdiff/Unified.golden internal/tui/exp/diffview/udiff_test.go → internal/ui/diffview/udiff_test.go internal/tui/exp/diffview/util.go → internal/ui/diffview/util.go internal/tui/exp/diffview/util_test.go → internal/ui/diffview/util_test.go internal/ui/image/image.go+3−3 internal/ui/logo/logo.go+7−8 internal/ui/model/filter.go+22 internal/ui/model/onboarding.go+2−2 internal/ui/model/session.go+3−3 internal/ui/model/sidebar.go+1−1 internal/ui/model/status.go+10−10 internal/ui/model/ui.go+52−52 internal/ui/styles/styles.go+1−1 internal/uiutil/uiutil.go → internal/ui/util/util.go+2−4 internal/uicmd/uicmd.go−314
go.mod+1−10
@@ -16,7 +16,6 @@ require (
1616 github.com/PuerkitoBio/goquery v1.11.0
1717 github.com/alecthomas/chroma/v2 v2.23.1
1818 github.com/atotto/clipboard v0.1.4
19− github.com/aymanbagabas/go-nativeclipboard v0.1.2
2019 github.com/aymanbagabas/go-udiff v0.3.1
2120 github.com/bmatcuk/doublestar/v4 v4.10.0
2221 github.com/charlievieth/fastwalk v1.0.14
@@ -36,7 +35,6 @@ require (
3635 github.com/clipperhouse/displaywidth v0.9.0
3736 github.com/clipperhouse/uax29/v2 v2.5.0
3837 github.com/denisbrodbeck/machineid v1.0.1
39− github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
4038 github.com/disintegration/imaging v1.6.2
4139 github.com/dustin/go-humanize v1.0.1
4240 github.com/google/uuid v1.6.0
@@ -46,9 +44,7 @@ require (
4644 github.com/lucasb-eyer/go-colorful v1.3.0
4745 github.com/mattn/go-isatty v0.0.20
4846 github.com/modelcontextprotocol/go-sdk v1.2.0
49− github.com/muesli/termenv v0.16.0
5047 github.com/ncruces/go-sqlite3 v0.30.5
51− github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
5248 github.com/nxadm/tail v1.4.11
5349 github.com/openai/openai-go/v2 v2.7.1
5450 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
@@ -59,13 +55,10 @@ require (
5955 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
6056 github.com/sahilm/fuzzy v0.1.1
6157 github.com/spf13/cobra v1.10.2
62− github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
63− github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
6458 github.com/stretchr/testify v1.11.1
6559 github.com/tidwall/gjson v1.18.0
6660 github.com/tidwall/sjson v1.2.5
6761 github.com/zeebo/xxh3 v1.1.0
68− golang.org/x/mod v0.32.0
6962 golang.org/x/net v0.49.0
7063 golang.org/x/sync v0.19.0
7164 golang.org/x/text v0.33.0
@@ -100,7 +93,6 @@ require (
10093 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
10194 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
10295 github.com/aws/smithy-go v1.24.0 // indirect
103− github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
10496 github.com/aymerick/douceur v0.2.0 // indirect
10597 github.com/bahlo/generic-list-go v0.2.0 // indirect
10698 github.com/buger/jsonparser v1.1.1 // indirect
@@ -111,9 +103,7 @@ require (
111103 github.com/charmbracelet/x/windows v0.2.2 // indirect
112104 github.com/clipperhouse/stringish v0.1.1 // indirect
113105 github.com/davecgh/go-spew v1.1.1 // indirect
114− github.com/disintegration/gift v1.1.2 // indirect
115106 github.com/dlclark/regexp2 v1.11.5 // indirect
116− github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
117107 github.com/felixge/httpsnoop v1.0.4 // indirect
118108 github.com/fsnotify/fsnotify v1.9.0 // indirect
119109 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -180,6 +170,7 @@ require (
180170 golang.org/x/crypto v0.47.0 // indirect
181171 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
182172 golang.org/x/image v0.34.0 // indirect
173+ golang.org/x/mod v0.32.0 // indirect
183174 golang.org/x/oauth2 v0.34.0 // indirect
184175 golang.org/x/sys v0.40.0 // indirect
185176 golang.org/x/term v0.39.0 // indirect
go.sum−18
@@ -80,10 +80,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
8080github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
8181github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
8282github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
83−github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM=
84−github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q=
85−github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
86−github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
8783github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
8884github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
8985github.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
148144github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
149145github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
150146github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
151−github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
152−github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
153−github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
154−github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
155147github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
156148github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
157149github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
158150github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
159151github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
160152github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
161−github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
162−github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
163153github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
164154github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
165155github.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
275265github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
276266github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
277267github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
278−github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
279−github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
280268github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
281269github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
282270github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
283271github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
284272github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
285273github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
286−github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
287−github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
288274github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
289275github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
290276github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
@@ -331,10 +317,6 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
331317github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
332318github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
333319github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
334−github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
335−github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
336−github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
337−github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
338320github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
339321github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
340322github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
internal/app/app.go+3−3
@@ -33,8 +33,8 @@ import (
3333 "github.com/charmbracelet/crush/internal/pubsub"
3434 "github.com/charmbracelet/crush/internal/session"
3535 "github.com/charmbracelet/crush/internal/shell"
36− "github.com/charmbracelet/crush/internal/tui/components/anim"
37− "github.com/charmbracelet/crush/internal/tui/styles"
36+ "github.com/charmbracelet/crush/internal/ui/anim"
37+ "github.com/charmbracelet/crush/internal/ui/styles"
3838 "github.com/charmbracelet/crush/internal/update"
3939 "github.com/charmbracelet/crush/internal/version"
4040 "github.com/charmbracelet/x/ansi"
@@ -160,7 +160,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
160160 progress = app.config.Options.Progress == nil || *app.config.Options.Progress
161161
162162 if !hideSpinner && stderrTTY {
163− t := styles.CurrentTheme()
163+ t := styles.DefaultStyles()
164164
165165 // Detect background color to set the appropriate color for the
166166 // spinner's 'Generating...' text. Without this, that text would be
internal/cmd/root.go+4−36
@@ -20,7 +20,6 @@ import (
2020 "github.com/charmbracelet/crush/internal/db"
2121 "github.com/charmbracelet/crush/internal/event"
2222 "github.com/charmbracelet/crush/internal/projects"
23− "github.com/charmbracelet/crush/internal/tui"
2423 "github.com/charmbracelet/crush/internal/ui/common"
2524 ui "github.com/charmbracelet/crush/internal/ui/model"
2625 "github.com/charmbracelet/crush/internal/version"
@@ -28,14 +27,10 @@ import (
2827 uv "github.com/charmbracelet/ultraviolet"
2928 "github.com/charmbracelet/x/ansi"
3029 "github.com/charmbracelet/x/exp/charmtone"
31− xstrings "github.com/charmbracelet/x/exp/strings"
3230 "github.com/charmbracelet/x/term"
3331 "github.com/spf13/cobra"
3432)
3533
36−// kittyTerminals defines terminals supporting querying capabilities.
37−var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
38−
3934func init() {
4035 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
4136 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
@@ -93,27 +88,15 @@ crush -y
9388 // Set up the TUI.
9489 var env uv.Environ = os.Environ()
9590
96− newUI := true
97− if v, err := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); err == nil {
98− newUI = v
99− }
91+ com := common.DefaultCommon(app)
92+ model := ui.New(com)
10093
101− var model tea.Model
102− if newUI {
103− slog.Info("New UI in control!")
104− com := common.DefaultCommon(app)
105− ui := ui.New(com)
106− model = ui
107− } else {
108− ui := tui.New(app)
109− ui.QueryVersion = shouldQueryCapabilities(env)
110− model = ui
111− }
11294 program := tea.NewProgram(
11395 model,
11496 tea.WithEnvironment(env),
11597 tea.WithContext(cmd.Context()),
116− tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
98+ tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
99+ )
117100 go app.Subscribe(program)
118101
119102 if _, err := program.Run(); err != nil {
@@ -313,18 +296,3 @@ func createDotCrushDir(dir string) error {
313296
314297 return nil
315298}
316−
317−// TODO: Remove me after dropping the old TUI.
318−func shouldQueryCapabilities(env uv.Environ) bool {
319− const osVendorTypeApple = "Apple"
320− termType := env.Getenv("TERM")
321− termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
322− _, okSSHTTY := env.LookupEnv("SSH_TTY")
323− if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
324− return false
325− }
326− return (!okTermProg && !okSSHTTY) ||
327− (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
328− // Terminals that do support XTVERSION.
329− xstrings.ContainsAnyOf(termType, kittyTerminals...)
330−}
internal/cmd/root_test.godeleted−160
@@ -1,160 +0,0 @@
1−package cmd
2−
3−import (
4− "strings"
5− "testing"
6−
7− uv "github.com/charmbracelet/ultraviolet"
8− xstrings "github.com/charmbracelet/x/exp/strings"
9− "github.com/stretchr/testify/require"
10−)
11−
12−type mockEnviron []string
13−
14−func (m mockEnviron) Getenv(key string) string {
15− v, _ := m.LookupEnv(key)
16− return v
17−}
18−
19−func (m mockEnviron) LookupEnv(key string) (string, bool) {
20− for _, env := range m {
21− kv := strings.SplitN(env, "=", 2)
22− if len(kv) == 2 && kv[0] == key {
23− return kv[1], true
24− }
25− }
26− return "", false
27−}
28−
29−func (m mockEnviron) ExpandEnv(s string) string {
30− return s // Not implemented for tests
31−}
32−
33−func (m mockEnviron) Slice() []string {
34− return []string(m)
35−}
36−
37−func TestShouldQueryImageCapabilities(t *testing.T) {
38− t.Parallel()
39−
40− tests := []struct {
41− name string
42− env mockEnviron
43− want bool
44− }{
45− {
46− name: "kitty terminal",
47− env: mockEnviron{"TERM=xterm-kitty"},
48− want: true,
49− },
50− {
51− name: "wezterm terminal",
52− env: mockEnviron{"TERM=xterm-256color"},
53− want: true,
54− },
55− {
56− name: "wezterm with WEZTERM env",
57− env: mockEnviron{"TERM=xterm-256color", "WEZTERM_EXECUTABLE=/Applications/WezTerm.app/Contents/MacOS/wezterm-gui"},
58− want: true, // Not detected via TERM, only via stringext.ContainsAny which checks TERM
59− },
60− {
61− name: "Apple Terminal",
62− env: mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-256color"},
63− want: false,
64− },
65− {
66− name: "alacritty",
67− env: mockEnviron{"TERM=alacritty"},
68− want: true,
69− },
70− {
71− name: "ghostty",
72− env: mockEnviron{"TERM=xterm-ghostty"},
73− want: true,
74− },
75− {
76− name: "rio",
77− env: mockEnviron{"TERM=rio"},
78− want: true,
79− },
80− {
81− name: "wezterm (detected via TERM)",
82− env: mockEnviron{"TERM=wezterm"},
83− want: true,
84− },
85− {
86− name: "SSH session",
87− env: mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-256color"},
88− want: false,
89− },
90− {
91− name: "generic terminal",
92− env: mockEnviron{"TERM=xterm-256color"},
93− want: true,
94− },
95− {
96− name: "kitty over SSH",
97− env: mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-kitty"},
98− want: true,
99− },
100− {
101− name: "Apple Terminal with kitty TERM (should still be false due to TERM_PROGRAM)",
102− env: mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-kitty"},
103− want: false,
104− },
105− }
106−
107− for _, tt := range tests {
108− t.Run(tt.name, func(t *testing.T) {
109− t.Parallel()
110− got := shouldQueryCapabilities(uv.Environ(tt.env))
111− require.Equal(t, tt.want, got, "shouldQueryImageCapabilities() = %v, want %v", got, tt.want)
112− })
113− }
114−}
115−
116−// This is a helper to test the underlying logic of stringext.ContainsAny
117−// which is used by shouldQueryImageCapabilities
118−func TestStringextContainsAny(t *testing.T) {
119− t.Parallel()
120−
121− tests := []struct {
122− name string
123− s string
124− substr []string
125− want bool
126− }{
127− {
128− name: "kitty in TERM",
129− s: "xterm-kitty",
130− substr: kittyTerminals,
131− want: true,
132− },
133− {
134− name: "wezterm in TERM",
135− s: "wezterm",
136− substr: kittyTerminals,
137− want: true,
138− },
139− {
140− name: "alacritty in TERM",
141− s: "alacritty",
142− substr: kittyTerminals,
143− want: true,
144− },
145− {
146− name: "generic terminal not in list",
147− s: "xterm-256color",
148− substr: kittyTerminals,
149− want: false,
150− },
151− }
152−
153− for _, tt := range tests {
154− t.Run(tt.name, func(t *testing.T) {
155− t.Parallel()
156− got := xstrings.ContainsAnyOf(tt.s, tt.substr...)
157− require.Equal(t, tt.want, got)
158− })
159− }
160−}
internal/format/spinner.go+7−6
@@ -7,7 +7,7 @@ import (
77 "os"
88
99 tea "charm.land/bubbletea/v2"
10− "github.com/charmbracelet/crush/internal/tui/components/anim"
10+ "github.com/charmbracelet/crush/internal/ui/anim"
1111 "github.com/charmbracelet/x/ansi"
1212)
1313
@@ -22,8 +22,8 @@ type model struct {
2222 anim *anim.Anim
2323}
2424
25−func (m model) Init() tea.Cmd { return m.anim.Init() }
26−func (m model) View() tea.View { return tea.NewView(m.anim.View()) }
25+func (m model) Init() tea.Cmd { return m.anim.Start() }
26+func (m model) View() tea.View { return tea.NewView(m.anim.Render()) }
2727
2828// Update implements tea.Model.
2929func (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) {
3434 m.cancel()
3535 return m, tea.Quit
3636 }
37+ case anim.StepMsg:
38+ cmd := m.anim.Animate(msg)
39+ return m, cmd
3740 }
38− mm, cmd := m.anim.Update(msg)
39− m.anim = mm.(*anim.Anim)
40− return m, cmd
41+ return m, nil
4142}
4243
4344// NewSpinner creates a new spinner with the given message
internal/tui/components/anim/anim.godeleted−447
@@ -1,447 +0,0 @@
1−// Package anim provides an animated spinner.
2−package anim
3−
4−import (
5− "fmt"
6− "image/color"
7− "math/rand/v2"
8− "strings"
9− "sync/atomic"
10− "time"
11−
12− "github.com/zeebo/xxh3"
13−
14− tea "charm.land/bubbletea/v2"
15− "charm.land/lipgloss/v2"
16− "github.com/lucasb-eyer/go-colorful"
17−
18− "github.com/charmbracelet/crush/internal/csync"
19− "github.com/charmbracelet/crush/internal/tui/util"
20−)
21−
22−const (
23− fps = 20
24− initialChar = '.'
25− labelGap = " "
26− labelGapWidth = 1
27−
28− // Periods of ellipsis animation speed in steps.
29− //
30− // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
31− // change every 8 frames (400 milliseconds).
32− ellipsisAnimSpeed = 8
33−
34− // The maximum amount of time that can pass before a character appears.
35− // This is used to create a staggered entrance effect.
36− maxBirthOffset = time.Second
37−
38− // Number of frames to prerender for the animation. After this number
39− // of frames, the animation will loop. This only applies when color
40− // cycling is disabled.
41− prerenderedFrames = 10
42−
43− // Default number of cycling chars.
44− defaultNumCyclingChars = 10
45−)
46−
47−// Default colors for gradient.
48−var (
49− defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
50− defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
51− defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
52−)
53−
54−var (
55− availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
56− ellipsisFrames = []string{".", "..", "...", ""}
57−)
58−
59−// Internal ID management. Used during animating to ensure that frame messages
60−// are received only by spinner components that sent them.
61−var lastID int64
62−
63−func nextID() int {
64− return int(atomic.AddInt64(&lastID, 1))
65−}
66−
67−// Cache for expensive animation calculations
68−type animCache struct {
69− initialFrames [][]string
70− cyclingFrames [][]string
71− width int
72− labelWidth int
73− label []string
74− ellipsisFrames []string
75−}
76−
77−var animCacheMap = csync.NewMap[string, *animCache]()
78−
79−// settingsHash creates a hash key for the settings to use for caching
80−func settingsHash(opts Settings) string {
81− h := xxh3.New()
82− fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
83− opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
84− return fmt.Sprintf("%x", h.Sum(nil))
85−}
86−
87−// StepMsg is a message type used to trigger the next step in the animation.
88−type StepMsg struct{ id int }
89−
90−// Settings defines settings for the animation.
91−type Settings struct {
92− Size int
93− Label string
94− LabelColor color.Color
95− GradColorA color.Color
96− GradColorB color.Color
97− CycleColors bool
98−}
99−
100−// Default settings.
101−const ()
102−
103−// Anim is a Bubble for an animated spinner.
104−type Anim struct {
105− width int
106− cyclingCharWidth int
107− label *csync.Slice[string]
108− labelWidth int
109− labelColor color.Color
110− startTime time.Time
111− birthOffsets []time.Duration
112− initialFrames [][]string // frames for the initial characters
113− initialized atomic.Bool
114− cyclingFrames [][]string // frames for the cycling characters
115− step atomic.Int64 // current main frame step
116− ellipsisStep atomic.Int64 // current ellipsis frame step
117− ellipsisFrames *csync.Slice[string] // ellipsis animation frames
118− id int
119−}
120−
121−// New creates a new Anim instance with the specified width and label.
122−func New(opts Settings) *Anim {
123− a := &Anim{}
124− // Validate settings.
125− if opts.Size < 1 {
126− opts.Size = defaultNumCyclingChars
127− }
128− if colorIsUnset(opts.GradColorA) {
129− opts.GradColorA = defaultGradColorA
130− }
131− if colorIsUnset(opts.GradColorB) {
132− opts.GradColorB = defaultGradColorB
133− }
134− if colorIsUnset(opts.LabelColor) {
135− opts.LabelColor = defaultLabelColor
136− }
137−
138− a.id = nextID()
139− a.startTime = time.Now()
140− a.cyclingCharWidth = opts.Size
141− a.labelColor = opts.LabelColor
142−
143− // Check cache first
144− cacheKey := settingsHash(opts)
145− cached, exists := animCacheMap.Get(cacheKey)
146−
147− if exists {
148− // Use cached values
149− a.width = cached.width
150− a.labelWidth = cached.labelWidth
151− a.label = csync.NewSliceFrom(cached.label)
152− a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
153− a.initialFrames = cached.initialFrames
154− a.cyclingFrames = cached.cyclingFrames
155− } else {
156− // Generate new values and cache them
157− a.labelWidth = lipgloss.Width(opts.Label)
158−
159− // Total width of anim, in cells.
160− a.width = opts.Size
161− if opts.Label != "" {
162− a.width += labelGapWidth + lipgloss.Width(opts.Label)
163− }
164−
165− // Render the label
166− a.renderLabel(opts.Label)
167−
168− // Pre-generate gradient.
169− var ramp []color.Color
170− numFrames := prerenderedFrames
171− if opts.CycleColors {
172− ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
173− numFrames = a.width * 2
174− } else {
175− ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
176− }
177−
178− // Pre-render initial characters.
179− a.initialFrames = make([][]string, numFrames)
180− offset := 0
181− for i := range a.initialFrames {
182− a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
183− for j := range a.initialFrames[i] {
184− if j+offset >= len(ramp) {
185− continue // skip if we run out of colors
186− }
187−
188− var c color.Color
189− if j <= a.cyclingCharWidth {
190− c = ramp[j+offset]
191− } else {
192− c = opts.LabelColor
193− }
194−
195− // Also prerender the initial character with Lip Gloss to avoid
196− // processing in the render loop.
197− a.initialFrames[i][j] = lipgloss.NewStyle().
198− Foreground(c).
199− Render(string(initialChar))
200− }
201− if opts.CycleColors {
202− offset++
203− }
204− }
205−
206− // Prerender scrambled rune frames for the animation.
207− a.cyclingFrames = make([][]string, numFrames)
208− offset = 0
209− for i := range a.cyclingFrames {
210− a.cyclingFrames[i] = make([]string, a.width)
211− for j := range a.cyclingFrames[i] {
212− if j+offset >= len(ramp) {
213− continue // skip if we run out of colors
214− }
215−
216− // Also prerender the color with Lip Gloss here to avoid processing
217− // in the render loop.
218− r := availableRunes[rand.IntN(len(availableRunes))]
219− a.cyclingFrames[i][j] = lipgloss.NewStyle().
220− Foreground(ramp[j+offset]).
221− Render(string(r))
222− }
223− if opts.CycleColors {
224− offset++
225− }
226− }
227−
228− // Cache the results
229− labelSlice := make([]string, a.label.Len())
230− for i, v := range a.label.Seq2() {
231− labelSlice[i] = v
232− }
233− ellipsisSlice := make([]string, a.ellipsisFrames.Len())
234− for i, v := range a.ellipsisFrames.Seq2() {
235− ellipsisSlice[i] = v
236− }
237− cached = &animCache{
238− initialFrames: a.initialFrames,
239− cyclingFrames: a.cyclingFrames,
240− width: a.width,
241− labelWidth: a.labelWidth,
242− label: labelSlice,
243− ellipsisFrames: ellipsisSlice,
244− }
245− animCacheMap.Set(cacheKey, cached)
246− }
247−
248− // Random assign a birth to each character for a stagged entrance effect.
249− a.birthOffsets = make([]time.Duration, a.width)
250− for i := range a.birthOffsets {
251− a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
252− }
253−
254− return a
255−}
256−
257−// SetLabel updates the label text and re-renders it.
258−func (a *Anim) SetLabel(newLabel string) {
259− a.labelWidth = lipgloss.Width(newLabel)
260−
261− // Update total width
262− a.width = a.cyclingCharWidth
263− if newLabel != "" {
264− a.width += labelGapWidth + a.labelWidth
265− }
266−
267− // Re-render the label
268− a.renderLabel(newLabel)
269−}
270−
271−// renderLabel renders the label with the current label color.
272−func (a *Anim) renderLabel(label string) {
273− if a.labelWidth > 0 {
274− // Pre-render the label.
275− labelRunes := []rune(label)
276− a.label = csync.NewSlice[string]()
277− for i := range labelRunes {
278− rendered := lipgloss.NewStyle().
279− Foreground(a.labelColor).
280− Render(string(labelRunes[i]))
281− a.label.Append(rendered)
282− }
283−
284− // Pre-render the ellipsis frames which come after the label.
285− a.ellipsisFrames = csync.NewSlice[string]()
286− for _, frame := range ellipsisFrames {
287− rendered := lipgloss.NewStyle().
288− Foreground(a.labelColor).
289− Render(frame)
290− a.ellipsisFrames.Append(rendered)
291− }
292− } else {
293− a.label = csync.NewSlice[string]()
294− a.ellipsisFrames = csync.NewSlice[string]()
295− }
296−}
297−
298−// Width returns the total width of the animation.
299−func (a *Anim) Width() (w int) {
300− w = a.width
301− if a.labelWidth > 0 {
302− w += labelGapWidth + a.labelWidth
303−
304− var widestEllipsisFrame int
305− for _, f := range ellipsisFrames {
306− fw := lipgloss.Width(f)
307− if fw > widestEllipsisFrame {
308− widestEllipsisFrame = fw
309− }
310− }
311− w += widestEllipsisFrame
312− }
313− return w
314−}
315−
316−// Init starts the animation.
317−func (a *Anim) Init() tea.Cmd {
318− return a.Step()
319−}
320−
321−// Update processes animation steps (or not).
322−func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
323− switch msg := msg.(type) {
324− case StepMsg:
325− if msg.id != a.id {
326− // Reject messages that are not for this instance.
327− return a, nil
328− }
329−
330− step := a.step.Add(1)
331− if int(step) >= len(a.cyclingFrames) {
332− a.step.Store(0)
333− }
334−
335− if a.initialized.Load() && a.labelWidth > 0 {
336− // Manage the ellipsis animation.
337− ellipsisStep := a.ellipsisStep.Add(1)
338− if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
339− a.ellipsisStep.Store(0)
340− }
341− } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
342− a.initialized.Store(true)
343− }
344− return a, a.Step()
345− default:
346− return a, nil
347− }
348−}
349−
350−// View renders the current state of the animation.
351−func (a *Anim) View() string {
352− var b strings.Builder
353− step := int(a.step.Load())
354− for i := range a.width {
355− switch {
356− case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
357− // Birth offset not reached: render initial character.
358− b.WriteString(a.initialFrames[step][i])
359− case i < a.cyclingCharWidth:
360− // Render a cycling character.
361− b.WriteString(a.cyclingFrames[step][i])
362− case i == a.cyclingCharWidth:
363− // Render label gap.
364− b.WriteString(labelGap)
365− case i > a.cyclingCharWidth:
366− // Label.
367− if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
368− b.WriteString(labelChar)
369− }
370− }
371− }
372− // Render animated ellipsis at the end of the label if all characters
373− // have been initialized.
374− if a.initialized.Load() && a.labelWidth > 0 {
375− ellipsisStep := int(a.ellipsisStep.Load())
376− if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
377− b.WriteString(ellipsisFrame)
378− }
379− }
380−
381− return b.String()
382−}
383−
384−// Step is a command that triggers the next step in the animation.
385−func (a *Anim) Step() tea.Cmd {
386− return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
387− return StepMsg{id: a.id}
388− })
389−}
390−
391−// makeGradientRamp() returns a slice of colors blended between the given keys.
392−// Blending is done as Hcl to stay in gamut.
393−func makeGradientRamp(size int, stops ...color.Color) []color.Color {
394− if len(stops) < 2 {
395− return nil
396− }
397−
398− points := make([]colorful.Color, len(stops))
399− for i, k := range stops {
400− points[i], _ = colorful.MakeColor(k)
401− }
402−
403− numSegments := len(stops) - 1
404− if numSegments == 0 {
405− return nil
406− }
407− blended := make([]color.Color, 0, size)
408−
409− // Calculate how many colors each segment should have.
410− segmentSizes := make([]int, numSegments)
411− baseSize := size / numSegments
412− remainder := size % numSegments
413−
414− // Distribute the remainder across segments.
415− for i := range numSegments {
416− segmentSizes[i] = baseSize
417− if i < remainder {
418− segmentSizes[i]++
419− }
420− }
421−
422− // Generate colors for each segment.
423− for i := range numSegments {
424− c1 := points[i]
425− c2 := points[i+1]
426− segmentSize := segmentSizes[i]
427−
428− for j := range segmentSize {
429− if segmentSize == 0 {
430− continue
431− }
432− t := float64(j) / float64(segmentSize)
433− c := c1.BlendHcl(c2, t)
434− blended = append(blended, c)
435− }
436− }
437−
438− return blended
439−}
440−
441−func colorIsUnset(c color.Color) bool {
442− if c == nil {
443− return true
444− }
445− _, _, _, a := c.RGBA()
446− return a == 0
447−}
internal/tui/components/chat/chat.godeleted−782
@@ -1,782 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "time"
6−
7− "charm.land/bubbles/v2/key"
8− tea "charm.land/bubbletea/v2"
9− "github.com/atotto/clipboard"
10− "github.com/charmbracelet/crush/internal/agent"
11− "github.com/charmbracelet/crush/internal/agent/tools"
12− "github.com/charmbracelet/crush/internal/app"
13− "github.com/charmbracelet/crush/internal/message"
14− "github.com/charmbracelet/crush/internal/permission"
15− "github.com/charmbracelet/crush/internal/pubsub"
16− "github.com/charmbracelet/crush/internal/session"
17− "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
18− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19− "github.com/charmbracelet/crush/internal/tui/exp/list"
20− "github.com/charmbracelet/crush/internal/tui/styles"
21− "github.com/charmbracelet/crush/internal/tui/util"
22−)
23−
24−type SendMsg struct {
25− Text string
26− Attachments []message.Attachment
27−}
28−
29−type SessionSelectedMsg = session.Session
30−
31−type SessionClearedMsg struct{}
32−
33−type SelectionCopyMsg struct {
34− clickCount int
35− endSelection bool
36− x, y int
37−}
38−
39−const (
40− NotFound = -1
41−)
42−
43−// MessageListCmp represents a component that displays a list of chat messages
44−// with support for real-time updates and session management.
45−type MessageListCmp interface {
46− util.Model
47− layout.Sizeable
48− layout.Focusable
49− layout.Help
50−
51− SetSession(session.Session) tea.Cmd
52− GoToBottom() tea.Cmd
53− GetSelectedText() string
54− CopySelectedText(bool) tea.Cmd
55−}
56−
57−// messageListCmp implements MessageListCmp, providing a virtualized list
58−// of chat messages with support for tool calls, real-time updates, and
59−// session switching.
60−type messageListCmp struct {
61− app *app.App
62− width, height int
63− session session.Session
64− listCmp list.List[list.Item]
65− previousSelected string // Last selected item index for restoring focus
66−
67− lastUserMessageTime int64
68− defaultListKeyMap list.KeyMap
69−
70− // Click tracking for double/triple click detection
71− lastClickTime time.Time
72− lastClickX int
73− lastClickY int
74− clickCount int
75−}
76−
77−// New creates a new message list component with custom keybindings
78−// and reverse ordering (newest messages at bottom).
79−func New(app *app.App) MessageListCmp {
80− defaultListKeyMap := list.DefaultKeyMap()
81− listCmp := list.New(
82− []list.Item{},
83− list.WithGap(1),
84− list.WithDirectionBackward(),
85− list.WithFocus(false),
86− list.WithKeyMap(defaultListKeyMap),
87− list.WithEnableMouse(),
88− )
89− return &messageListCmp{
90− app: app,
91− listCmp: listCmp,
92− previousSelected: "",
93− defaultListKeyMap: defaultListKeyMap,
94− }
95−}
96−
97−// Init initializes the component.
98−func (m *messageListCmp) Init() tea.Cmd {
99− return m.listCmp.Init()
100−}
101−
102−// Update handles incoming messages and updates the component state.
103−func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
104− var cmds []tea.Cmd
105− switch msg := msg.(type) {
106− case tea.KeyPressMsg:
107− if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
108− switch {
109− case key.Matches(msg, messages.CopyKey):
110− cmds = append(cmds, m.CopySelectedText(true))
111− return m, tea.Batch(cmds...)
112− case key.Matches(msg, messages.ClearSelectionKey):
113− cmds = append(cmds, m.SelectionClear())
114− return m, tea.Batch(cmds...)
115− }
116− }
117− case tea.MouseClickMsg:
118− x := msg.X - 1 // Adjust for padding
119− y := msg.Y - 1 // Adjust for padding
120− if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
121− return m, nil // Ignore clicks outside the component
122− }
123− if msg.Button == tea.MouseLeft {
124− cmds = append(cmds, m.handleMouseClick(x, y))
125− return m, tea.Batch(cmds...)
126− }
127− return m, tea.Batch(cmds...)
128− case tea.MouseMotionMsg:
129− x := msg.X - 1 // Adjust for padding
130− y := msg.Y - 1 // Adjust for padding
131− if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
132− if y < 0 {
133− cmds = append(cmds, m.listCmp.MoveUp(1))
134− return m, tea.Batch(cmds...)
135− }
136− if y >= m.height-1 {
137− cmds = append(cmds, m.listCmp.MoveDown(1))
138− return m, tea.Batch(cmds...)
139− }
140− return m, nil // Ignore clicks outside the component
141− }
142− if msg.Button == tea.MouseLeft {
143− m.listCmp.EndSelection(x, y)
144− }
145− return m, tea.Batch(cmds...)
146− case tea.MouseReleaseMsg:
147− x := msg.X - 1 // Adjust for padding
148− y := msg.Y - 1 // Adjust for padding
149− if msg.Button == tea.MouseLeft {
150− clickCount := m.clickCount
151− if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
152− tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
153− return SelectionCopyMsg{
154− clickCount: clickCount,
155− endSelection: false,
156− }
157− })
158−
159− cmds = append(cmds, tick)
160− return m, tea.Batch(cmds...)
161− }
162− tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
163− return SelectionCopyMsg{
164− clickCount: clickCount,
165− endSelection: true,
166− x: x,
167− y: y,
168− }
169− })
170− cmds = append(cmds, tick)
171− return m, tea.Batch(cmds...)
172− }
173− return m, nil
174− case SelectionCopyMsg:
175− if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
176− // If the click count matches and within threshold, copy selected text
177− if msg.endSelection {
178− m.listCmp.EndSelection(msg.x, msg.y)
179− }
180− m.listCmp.SelectionStop()
181− cmds = append(cmds, m.CopySelectedText(true))
182− return m, tea.Batch(cmds...)
183− }
184− case pubsub.Event[permission.PermissionNotification]:
185− cmds = append(cmds, m.handlePermissionRequest(msg.Payload))
186− return m, tea.Batch(cmds...)
187− case SessionSelectedMsg:
188− if msg.ID != m.session.ID {
189− cmds = append(cmds, m.SetSession(msg))
190− }
191− return m, tea.Batch(cmds...)
192− case SessionClearedMsg:
193− m.session = session.Session{}
194− cmds = append(cmds, m.listCmp.SetItems([]list.Item{}))
195− return m, tea.Batch(cmds...)
196−
197− case pubsub.Event[message.Message]:
198− cmds = append(cmds, m.handleMessageEvent(msg))
199− return m, tea.Batch(cmds...)
200−
201− case tea.MouseWheelMsg:
202− u, cmd := m.listCmp.Update(msg)
203− m.listCmp = u.(list.List[list.Item])
204− cmds = append(cmds, cmd)
205− return m, tea.Batch(cmds...)
206− }
207−
208− u, cmd := m.listCmp.Update(msg)
209− m.listCmp = u.(list.List[list.Item])
210− cmds = append(cmds, cmd)
211− return m, tea.Batch(cmds...)
212−}
213−
214−// View renders the message list or an initial screen if empty.
215−func (m *messageListCmp) View() string {
216− t := styles.CurrentTheme()
217− return t.S().Base.
218− Padding(1, 1, 0, 1).
219− Width(m.width).
220− Height(m.height).
221− Render(m.listCmp.View())
222−}
223−
224−func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
225− items := m.listCmp.Items()
226− if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
227− toolCall := items[toolCallIndex].(messages.ToolCallCmp)
228− toolCall.SetPermissionRequested()
229− if permission.Granted {
230− toolCall.SetPermissionGranted()
231− }
232− m.listCmp.UpdateItem(toolCall.ID(), toolCall)
233− }
234− return nil
235−}
236−
237−// handleChildSession handles messages from child sessions (agent tools).
238−func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
239− var cmds []tea.Cmd
240− if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
241− return nil
242− }
243−
244− // Check if this is an agent tool session and parse it
245− childSessionID := event.Payload.SessionID
246− parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID)
247− if !ok {
248− return nil
249− }
250− items := m.listCmp.Items()
251− toolCallInx := NotFound
252− var toolCall messages.ToolCallCmp
253− for i := len(items) - 1; i >= 0; i-- {
254− if msg, ok := items[i].(messages.ToolCallCmp); ok {
255− if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID {
256− toolCallInx = i
257− toolCall = msg
258− }
259− }
260− }
261− if toolCallInx == NotFound {
262− return nil
263− }
264− nestedToolCalls := toolCall.GetNestedToolCalls()
265− for _, tc := range event.Payload.ToolCalls() {
266− found := false
267− for existingInx, existingTC := range nestedToolCalls {
268− if existingTC.GetToolCall().ID == tc.ID {
269− nestedToolCalls[existingInx].SetToolCall(tc)
270− found = true
271− break
272− }
273− }
274− if !found {
275− nestedCall := messages.NewToolCallCmp(
276− event.Payload.ID,
277− tc,
278− m.app.Permissions,
279− messages.WithToolCallNested(true),
280− )
281− cmds = append(cmds, nestedCall.Init())
282− nestedToolCalls = append(
283− nestedToolCalls,
284− nestedCall,
285− )
286− }
287− }
288− for _, tr := range event.Payload.ToolResults() {
289− for nestedInx, nestedTC := range nestedToolCalls {
290− if nestedTC.GetToolCall().ID == tr.ToolCallID {
291− nestedToolCalls[nestedInx].SetToolResult(tr)
292− break
293− }
294− }
295− }
296−
297− toolCall.SetNestedToolCalls(nestedToolCalls)
298− m.listCmp.UpdateItem(
299− toolCall.ID(),
300− toolCall,
301− )
302− return tea.Batch(cmds...)
303−}
304−
305−// handleMessageEvent processes different types of message events (created/updated).
306−func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
307− switch event.Type {
308− case pubsub.CreatedEvent:
309− if event.Payload.SessionID != m.session.ID {
310− return m.handleChildSession(event)
311− }
312− if m.messageExists(event.Payload.ID) {
313− return nil
314− }
315− return m.handleNewMessage(event.Payload)
316− case pubsub.DeletedEvent:
317− if event.Payload.SessionID != m.session.ID {
318− return nil
319− }
320− return m.handleDeleteMessage(event.Payload)
321− case pubsub.UpdatedEvent:
322− if event.Payload.SessionID != m.session.ID {
323− return m.handleChildSession(event)
324− }
325− switch event.Payload.Role {
326− case message.Assistant:
327− return m.handleUpdateAssistantMessage(event.Payload)
328− case message.Tool:
329− return m.handleToolMessage(event.Payload)
330− }
331− }
332− return nil
333−}
334−
335−// messageExists checks if a message with the given ID already exists in the list.
336−func (m *messageListCmp) messageExists(messageID string) bool {
337− items := m.listCmp.Items()
338− // Search backwards as new messages are more likely to be at the end
339− for i := len(items) - 1; i >= 0; i-- {
340− if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
341− return true
342− }
343− }
344− return false
345−}
346−
347−// handleDeleteMessage removes a message from the list.
348−func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
349− items := m.listCmp.Items()
350− for i := len(items) - 1; i >= 0; i-- {
351− if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
352− m.listCmp.DeleteItem(items[i].ID())
353− return nil
354− }
355− }
356− return nil
357−}
358−
359−// handleNewMessage routes new messages to appropriate handlers based on role.
360−func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
361− switch msg.Role {
362− case message.User:
363− return m.handleNewUserMessage(msg)
364− case message.Assistant:
365− return m.handleNewAssistantMessage(msg)
366− case message.Tool:
367− return m.handleToolMessage(msg)
368− }
369− return nil
370−}
371−
372−// handleNewUserMessage adds a new user message to the list and updates the timestamp.
373−func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
374− m.lastUserMessageTime = msg.CreatedAt
375− return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
376−}
377−
378−// handleToolMessage updates existing tool calls with their results.
379−func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
380− items := m.listCmp.Items()
381− for _, tr := range msg.ToolResults() {
382− if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
383− toolCall := items[toolCallIndex].(messages.ToolCallCmp)
384− toolCall.SetToolResult(tr)
385− m.listCmp.UpdateItem(toolCall.ID(), toolCall)
386− }
387− }
388− return nil
389−}
390−
391−// findToolCallByID searches for a tool call with the specified ID.
392−// Returns the index if found, NotFound otherwise.
393−func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
394− // Search backwards as tool calls are more likely to be recent
395− for i := len(items) - 1; i >= 0; i-- {
396− if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
397− return i
398− }
399− }
400− return NotFound
401−}
402−
403−// handleUpdateAssistantMessage processes updates to assistant messages,
404−// managing both message content and associated tool calls.
405−func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
406− var cmds []tea.Cmd
407− items := m.listCmp.Items()
408−
409− // Find existing assistant message and tool calls for this message
410− assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
411−
412− // Handle assistant message content
413− if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
414− cmds = append(cmds, cmd)
415− }
416−
417− // Handle tool calls
418− if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
419− cmds = append(cmds, cmd)
420− }
421−
422− return tea.Batch(cmds...)
423−}
424−
425−// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
426−func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
427− assistantIndex := NotFound
428− toolCalls := make(map[int]messages.ToolCallCmp)
429−
430− // Search backwards as messages are more likely to be at the end
431− for i := len(items) - 1; i >= 0; i-- {
432− item := items[i]
433− if asMsg, ok := item.(messages.MessageCmp); ok {
434− if asMsg.GetMessage().ID == messageID {
435− assistantIndex = i
436− }
437− } else if tc, ok := item.(messages.ToolCallCmp); ok {
438− if tc.ParentMessageID() == messageID {
439− toolCalls[i] = tc
440− }
441− }
442− }
443−
444− return assistantIndex, toolCalls
445−}
446−
447−// updateAssistantMessageContent updates or removes the assistant message based on content.
448−func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
449− if assistantIndex == NotFound {
450− return nil
451− }
452−
453− shouldShowMessage := m.shouldShowAssistantMessage(msg)
454− hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
455−
456− var cmd tea.Cmd
457− if shouldShowMessage {
458− items := m.listCmp.Items()
459− uiMsg := items[assistantIndex].(messages.MessageCmp)
460− uiMsg.SetMessage(msg)
461− m.listCmp.UpdateItem(
462− items[assistantIndex].ID(),
463− uiMsg,
464− )
465− if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
466− m.listCmp.AppendItem(
467− messages.NewAssistantSection(
468− msg,
469− time.Unix(m.lastUserMessageTime, 0),
470− ),
471− )
472− }
473− } else if hasToolCallsOnly {
474− items := m.listCmp.Items()
475− m.listCmp.DeleteItem(items[assistantIndex].ID())
476− }
477−
478− return cmd
479−}
480−
481−// shouldShowAssistantMessage determines if an assistant message should be displayed.
482−func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
483− return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
484−}
485−
486−// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
487−func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
488− var cmds []tea.Cmd
489−
490− for _, tc := range msg.ToolCalls() {
491− if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
492− cmds = append(cmds, cmd)
493− }
494− }
495−
496− return tea.Batch(cmds...)
497−}
498−
499−// updateOrAddToolCall updates an existing tool call or adds a new one.
500−func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
501− // Try to find existing tool call
502− for _, existingTC := range existingToolCalls {
503− if tc.ID == existingTC.GetToolCall().ID {
504− existingTC.SetToolCall(tc)
505− if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
506− existingTC.SetCancelled()
507− }
508− m.listCmp.UpdateItem(tc.ID, existingTC)
509− return nil
510− }
511− }
512−
513− // Add new tool call if not found
514− return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
515−}
516−
517−// handleNewAssistantMessage processes new assistant messages and their tool calls.
518−func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
519− var cmds []tea.Cmd
520−
521− // Add assistant message if it should be displayed
522− if m.shouldShowAssistantMessage(msg) {
523− cmd := m.listCmp.AppendItem(
524− messages.NewMessageCmp(
525− msg,
526− ),
527− )
528− cmds = append(cmds, cmd)
529− }
530−
531− // Add tool calls
532− for _, tc := range msg.ToolCalls() {
533− cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
534− cmds = append(cmds, cmd)
535− }
536−
537− return tea.Batch(cmds...)
538−}
539−
540−// SetSession loads and displays messages for a new session.
541−func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
542− if m.session.ID == session.ID {
543− return nil
544− }
545−
546− m.session = session
547− sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
548− if err != nil {
549− return util.ReportError(err)
550− }
551−
552− if len(sessionMessages) == 0 {
553− return m.listCmp.SetItems([]list.Item{})
554− }
555−
556− // Initialize with first message timestamp
557− m.lastUserMessageTime = sessionMessages[0].CreatedAt
558−
559− // Build tool result map for efficient lookup
560− toolResultMap := m.buildToolResultMap(sessionMessages)
561−
562− // Convert messages to UI components
563− uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
564−
565− return m.listCmp.SetItems(uiMessages)
566−}
567−
568−// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
569−func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
570− toolResultMap := make(map[string]message.ToolResult)
571− for _, msg := range messages {
572− for _, tr := range msg.ToolResults() {
573− toolResultMap[tr.ToolCallID] = tr
574− }
575− }
576− return toolResultMap
577−}
578−
579−// convertMessagesToUI converts database messages to UI components.
580−func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
581− uiMessages := make([]list.Item, 0)
582−
583− for _, msg := range sessionMessages {
584− switch msg.Role {
585− case message.User:
586− m.lastUserMessageTime = msg.CreatedAt
587− uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
588− case message.Assistant:
589− uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
590− if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
591− uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
592− }
593− }
594− }
595−
596− return uiMessages
597−}
598−
599−// convertAssistantMessage converts an assistant message and its tool calls to UI components.
600−func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
601− var uiMessages []list.Item
602−
603− // Add assistant message if it should be displayed
604− if m.shouldShowAssistantMessage(msg) {
605− uiMessages = append(
606− uiMessages,
607− messages.NewMessageCmp(
608− msg,
609− ),
610− )
611− }
612−
613− // Add tool calls with their results and status
614− for _, tc := range msg.ToolCalls() {
615− options := m.buildToolCallOptions(tc, msg, toolResultMap)
616− uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
617− // If this tool call is the agent tool or agentic fetch, fetch nested tool calls
618− if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
619− agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
620− nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
621− nestedToolResultMap := m.buildToolResultMap(nestedMessages)
622− nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
623− nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
624− for _, nestedMsg := range nestedUIMessages {
625− if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
626− toolCall.SetIsNested(true)
627− nestedToolCalls = append(nestedToolCalls, toolCall)
628− }
629− }
630− uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
631− }
632− }
633−
634− return uiMessages
635−}
636−
637−// buildToolCallOptions creates options for tool call components based on results and status.
638−func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
639− var options []messages.ToolCallOption
640−
641− // Add tool result if available
642− if tr, ok := toolResultMap[tc.ID]; ok {
643− options = append(options, messages.WithToolCallResult(tr))
644− }
645−
646− // Add cancelled status if applicable
647− if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
648− options = append(options, messages.WithToolCallCancelled())
649− }
650−
651− return options
652−}
653−
654−// GetSize returns the current width and height of the component.
655−func (m *messageListCmp) GetSize() (int, int) {
656− return m.width, m.height
657−}
658−
659−// SetSize updates the component dimensions and propagates to the list component.
660−func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
661− m.width = width
662− m.height = height
663− return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding
664−}
665−
666−// Blur implements MessageListCmp.
667−func (m *messageListCmp) Blur() tea.Cmd {
668− return m.listCmp.Blur()
669−}
670−
671−// Focus implements MessageListCmp.
672−func (m *messageListCmp) Focus() tea.Cmd {
673− return m.listCmp.Focus()
674−}
675−
676−// IsFocused implements MessageListCmp.
677−func (m *messageListCmp) IsFocused() bool {
678− return m.listCmp.IsFocused()
679−}
680−
681−func (m *messageListCmp) Bindings() []key.Binding {
682− return m.defaultListKeyMap.KeyBindings()
683−}
684−
685−func (m *messageListCmp) GoToBottom() tea.Cmd {
686− return m.listCmp.GoToBottom()
687−}
688−
689−const (
690− doubleClickThreshold = 500 * time.Millisecond
691− clickTolerance = 2 // pixels
692−)
693−
694−// handleMouseClick handles mouse click events and detects double/triple clicks.
695−func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
696− now := time.Now()
697−
698− // Check if this is a potential multi-click
699− if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
700− abs(x-m.lastClickX) <= clickTolerance &&
701− abs(y-m.lastClickY) <= clickTolerance {
702− m.clickCount++
703− } else {
704− m.clickCount = 1
705− }
706−
707− m.lastClickTime = now
708− m.lastClickX = x
709− m.lastClickY = y
710−
711− switch m.clickCount {
712− case 1:
713− // Single click - start selection
714− m.listCmp.StartSelection(x, y)
715− case 2:
716− // Double click - select word
717− m.listCmp.SelectWord(x, y)
718− case 3:
719− // Triple click - select paragraph
720− m.listCmp.SelectParagraph(x, y)
721− m.clickCount = 0 // Reset after triple click
722− }
723−
724− return nil
725−}
726−
727−// SelectionClear clears the current selection in the list component.
728−func (m *messageListCmp) SelectionClear() tea.Cmd {
729− m.listCmp.SelectionClear()
730− m.previousSelected = ""
731− m.lastClickX, m.lastClickY = 0, 0
732− m.lastClickTime = time.Time{}
733− m.clickCount = 0
734− return nil
735−}
736−
737−// HasSelection checks if there is a selection in the list component.
738−func (m *messageListCmp) HasSelection() bool {
739− return m.listCmp.HasSelection()
740−}
741−
742−// GetSelectedText returns the currently selected text from the list component.
743−func (m *messageListCmp) GetSelectedText() string {
744− return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
745−}
746−
747−// CopySelectedText copies the currently selected text to the clipboard. When
748−// clear is true, it clears the selection after copying.
749−func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
750− if !m.listCmp.HasSelection() {
751− return nil
752− }
753−
754− selectedText := m.GetSelectedText()
755− if selectedText == "" {
756− return util.ReportInfo("No text selected")
757− }
758−
759− cmds := []tea.Cmd{
760− // We use both OSC 52 and native clipboard for compatibility with different
761− // terminal emulators and environments.
762− tea.SetClipboard(selectedText),
763− func() tea.Msg {
764− _ = clipboard.WriteAll(selectedText)
765− return nil
766− },
767− util.ReportInfo("Selected text copied to clipboard"),
768− }
769− if clear {
770− cmds = append(cmds, m.SelectionClear())
771− }
772−
773− return tea.Sequence(cmds...)
774−}
775−
776−// abs returns the absolute value of an integer.
777−func abs(x int) int {
778− if x < 0 {
779− return -x
780− }
781− return x
782−}
internal/tui/components/chat/editor/clipboard.godeleted−8
@@ -1,8 +0,0 @@
1−package editor
2−
3−type clipboardFormat int
4−
5−const (
6− clipboardFormatText clipboardFormat = iota
7− clipboardFormatImage
8−)
internal/tui/components/chat/editor/clipboard_not_supported.godeleted−7
@@ -1,7 +0,0 @@
1−//go:build !(darwin || linux || windows) || arm || 386 || ios || android
2−
3−package editor
4−
5−func readClipboard(clipboardFormat) ([]byte, error) {
6− return nil, errClipboardPlatformUnsupported
7−}
internal/tui/components/chat/editor/clipboard_supported.godeleted−15
@@ -1,15 +0,0 @@
1−//go:build (linux || darwin || windows) && !arm && !386 && !ios && !android
2−
3−package editor
4−
5−import "github.com/aymanbagabas/go-nativeclipboard"
6−
7−func readClipboard(f clipboardFormat) ([]byte, error) {
8− switch f {
9− case clipboardFormatText:
10− return nativeclipboard.Text.Read()
11− case clipboardFormatImage:
12− return nativeclipboard.Image.Read()
13− }
14− return nil, errClipboardUnknownFormat
15−}
internal/tui/components/chat/editor/editor.godeleted−780
@@ -1,780 +0,0 @@
1−package editor
2−
3−import (
4− "context"
5− "fmt"
6− "math/rand"
7− "net/http"
8− "os"
9− "path/filepath"
10− "regexp"
11− "slices"
12− "strconv"
13− "strings"
14− "unicode"
15−
16− "charm.land/bubbles/v2/key"
17− "charm.land/bubbles/v2/textarea"
18− tea "charm.land/bubbletea/v2"
19− "charm.land/lipgloss/v2"
20− "github.com/charmbracelet/crush/internal/app"
21− "github.com/charmbracelet/crush/internal/fsext"
22− "github.com/charmbracelet/crush/internal/message"
23− "github.com/charmbracelet/crush/internal/session"
24− "github.com/charmbracelet/crush/internal/tui/components/chat"
25− "github.com/charmbracelet/crush/internal/tui/components/completions"
26− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
27− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
28− "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
29− "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30− "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
31− "github.com/charmbracelet/crush/internal/tui/styles"
32− "github.com/charmbracelet/crush/internal/tui/util"
33− "github.com/charmbracelet/x/ansi"
34− "github.com/charmbracelet/x/editor"
35−)
36−
37−var (
38− errClipboardPlatformUnsupported = fmt.Errorf("clipboard operations are not supported on this platform")
39− errClipboardUnknownFormat = fmt.Errorf("unknown clipboard format")
40−)
41−
42−// If pasted text has more than 10 newlines, treat it as a file attachment.
43−const pasteLinesThreshold = 10
44−
45−type Editor interface {
46− util.Model
47− layout.Sizeable
48− layout.Focusable
49− layout.Help
50− layout.Positional
51−
52− SetSession(session session.Session) tea.Cmd
53− IsCompletionsOpen() bool
54− HasAttachments() bool
55− IsEmpty() bool
56− Cursor() *tea.Cursor
57−}
58−
59−type FileCompletionItem struct {
60− Path string // The file path
61−}
62−
63−type editorCmp struct {
64− width int
65− height int
66− x, y int
67− app *app.App
68− session session.Session
69− sessionFileReads []string
70− textarea textarea.Model
71− attachments []message.Attachment
72− deleteMode bool
73− readyPlaceholder string
74− workingPlaceholder string
75−
76− keyMap EditorKeyMap
77−
78− // File path completions
79− currentQuery string
80− completionsStartIndex int
81− isCompletionsOpen bool
82−}
83−
84−var DeleteKeyMaps = DeleteAttachmentKeyMaps{
85− AttachmentDeleteMode: key.NewBinding(
86− key.WithKeys("ctrl+r"),
87− key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
88− ),
89− Escape: key.NewBinding(
90− key.WithKeys("esc", "alt+esc"),
91− key.WithHelp("esc", "cancel delete mode"),
92− ),
93− DeleteAllAttachments: key.NewBinding(
94− key.WithKeys("r"),
95− key.WithHelp("ctrl+r+r", "delete all attachments"),
96− ),
97−}
98−
99−const maxFileResults = 25
100−
101−type OpenEditorMsg struct {
102− Text string
103−}
104−
105−func (m *editorCmp) openEditor(value string) tea.Cmd {
106− tmpfile, err := os.CreateTemp("", "msg_*.md")
107− if err != nil {
108− return util.ReportError(err)
109− }
110− defer tmpfile.Close() //nolint:errcheck
111− if _, err := tmpfile.WriteString(value); err != nil {
112− return util.ReportError(err)
113− }
114− cmd, err := editor.Command(
115− "crush",
116− tmpfile.Name(),
117− editor.AtPosition(
118− m.textarea.Line()+1,
119− m.textarea.Column()+1,
120− ),
121− )
122− if err != nil {
123− return util.ReportError(err)
124− }
125− return tea.ExecProcess(cmd, func(err error) tea.Msg {
126− if err != nil {
127− return util.ReportError(err)
128− }
129− content, err := os.ReadFile(tmpfile.Name())
130− if err != nil {
131− return util.ReportError(err)
132− }
133− if len(content) == 0 {
134− return util.ReportWarn("Message is empty")
135− }
136− os.Remove(tmpfile.Name())
137− return OpenEditorMsg{
138− Text: strings.TrimSpace(string(content)),
139− }
140− })
141−}
142−
143−func (m *editorCmp) Init() tea.Cmd {
144− return nil
145−}
146−
147−func (m *editorCmp) send() tea.Cmd {
148− value := m.textarea.Value()
149− value = strings.TrimSpace(value)
150−
151− switch value {
152− case "exit", "quit":
153− m.textarea.Reset()
154− return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
155− }
156−
157− attachments := m.attachments
158−
159− if value == "" && !message.ContainsTextAttachment(attachments) {
160− return nil
161− }
162−
163− m.textarea.Reset()
164− m.attachments = nil
165− // Change the placeholder when sending a new message.
166− m.randomizePlaceholders()
167−
168− return tea.Batch(
169− util.CmdHandler(chat.SendMsg{
170− Text: value,
171− Attachments: attachments,
172− }),
173− )
174−}
175−
176−func (m *editorCmp) repositionCompletions() tea.Msg {
177− x, y := m.completionsPosition()
178− return completions.RepositionCompletionsMsg{X: x, Y: y}
179−}
180−
181−func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
182− var cmd tea.Cmd
183− var cmds []tea.Cmd
184− switch msg := msg.(type) {
185− case chat.SessionClearedMsg:
186− m.session = session.Session{}
187− m.sessionFileReads = nil
188− case tea.WindowSizeMsg:
189− return m, m.repositionCompletions
190− case filepicker.FilePickedMsg:
191− m.attachments = append(m.attachments, msg.Attachment)
192− return m, nil
193− case completions.CompletionsOpenedMsg:
194− m.isCompletionsOpen = true
195− case completions.CompletionsClosedMsg:
196− m.isCompletionsOpen = false
197− m.currentQuery = ""
198− m.completionsStartIndex = 0
199− case completions.SelectCompletionMsg:
200− if !m.isCompletionsOpen {
201− return m, nil
202− }
203− if item, ok := msg.Value.(FileCompletionItem); ok {
204− word := m.textarea.Word()
205− // If the selected item is a file, insert its path into the textarea
206− value := m.textarea.Value()
207− value = value[:m.completionsStartIndex] + // Remove the current query
208− item.Path + // Insert the file path
209− value[m.completionsStartIndex+len(word):] // Append the rest of the value
210− // XXX: This will always move the cursor to the end of the textarea.
211− m.textarea.SetValue(value)
212− m.textarea.MoveToEnd()
213− if !msg.Insert {
214− m.isCompletionsOpen = false
215− m.currentQuery = ""
216− m.completionsStartIndex = 0
217− }
218− absPath, _ := filepath.Abs(item.Path)
219−
220− ctx := context.Background()
221−
222− // Skip attachment if file was already read and hasn't been modified.
223− if m.session.ID != "" {
224− lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
225− if !lastRead.IsZero() {
226− if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
227− return m, nil
228− }
229− }
230− } else if slices.Contains(m.sessionFileReads, absPath) {
231− return m, nil
232− }
233−
234− m.sessionFileReads = append(m.sessionFileReads, absPath)
235− content, err := os.ReadFile(item.Path)
236− if err != nil {
237− // if it fails, let the LLM handle it later.
238− return m, nil
239− }
240− m.attachments = append(m.attachments, message.Attachment{
241− FilePath: item.Path,
242− FileName: filepath.Base(item.Path),
243− MimeType: mimeOf(content),
244− Content: content,
245− })
246− }
247−
248− case commands.OpenExternalEditorMsg:
249− if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
250− return m, util.ReportWarn("Agent is working, please wait...")
251− }
252− return m, m.openEditor(m.textarea.Value())
253− case OpenEditorMsg:
254− m.textarea.SetValue(msg.Text)
255− m.textarea.MoveToEnd()
256− case tea.PasteMsg:
257− if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
258− content := []byte(msg.Content)
259− if len(content) > maxAttachmentSize {
260− return m, util.ReportWarn("Paste is too big (>5mb)")
261− }
262− name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
263− mimeType := mimeOf(content)
264− attachment := message.Attachment{
265− FileName: name,
266− FilePath: name,
267− MimeType: mimeType,
268− Content: content,
269− }
270− return m, util.CmdHandler(filepicker.FilePickedMsg{
271− Attachment: attachment,
272− })
273− }
274−
275− // Try to parse as a file path.
276− content, path, err := filepathToFile(msg.Content)
277− if err != nil {
278− // Not a file path, just update the textarea normally.
279− m.textarea, cmd = m.textarea.Update(msg)
280− return m, cmd
281− }
282−
283− if len(content) > maxAttachmentSize {
284− return m, util.ReportWarn("File is too big (>5mb)")
285− }
286−
287− mimeType := mimeOf(content)
288− attachment := message.Attachment{
289− FilePath: path,
290− FileName: filepath.Base(path),
291− MimeType: mimeType,
292− Content: content,
293− }
294− if !attachment.IsText() && !attachment.IsImage() {
295− return m, util.ReportWarn("Invalid file content type: " + mimeType)
296− }
297− return m, util.CmdHandler(filepicker.FilePickedMsg{
298− Attachment: attachment,
299− })
300−
301− case commands.ToggleYoloModeMsg:
302− m.setEditorPrompt()
303− return m, nil
304− case tea.KeyPressMsg:
305− cur := m.textarea.Cursor()
306− curIdx := m.textarea.Width()*cur.Y + cur.X
307− switch {
308− // Open command palette when "/" is pressed on empty prompt
309− case msg.String() == "/" && m.IsEmpty():
310− return m, util.CmdHandler(dialogs.OpenDialogMsg{
311− Model: commands.NewCommandDialog(m.session.ID),
312− })
313− // Completions
314− case msg.String() == "@" && !m.isCompletionsOpen &&
315− // only show if beginning of prompt, or if previous char is a space or newline:
316− (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
317− m.isCompletionsOpen = true
318− m.currentQuery = ""
319− m.completionsStartIndex = curIdx
320− cmds = append(cmds, m.startCompletions)
321− case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
322− cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
323− }
324− if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
325− m.deleteMode = true
326− return m, nil
327− }
328− if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
329− m.deleteMode = false
330− m.attachments = nil
331− return m, nil
332− }
333− rune := msg.Code
334− if m.deleteMode && unicode.IsDigit(rune) {
335− num := int(rune - '0')
336− m.deleteMode = false
337− if num < 10 && len(m.attachments) > num {
338− if num == 0 {
339− m.attachments = m.attachments[num+1:]
340− } else {
341− m.attachments = slices.Delete(m.attachments, num, num+1)
342− }
343− return m, nil
344− }
345− }
346− if key.Matches(msg, m.keyMap.OpenEditor) {
347− if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
348− return m, util.ReportWarn("Agent is working, please wait...")
349− }
350− return m, m.openEditor(m.textarea.Value())
351− }
352− if key.Matches(msg, DeleteKeyMaps.Escape) {
353− m.deleteMode = false
354− return m, nil
355− }
356− if key.Matches(msg, m.keyMap.Newline) {
357− m.textarea.InsertRune('\n')
358− cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
359− }
360− // Handle image paste from clipboard
361− if key.Matches(msg, m.keyMap.PasteImage) {
362− imageData, err := readClipboard(clipboardFormatImage)
363−
364− if err != nil || len(imageData) == 0 {
365− // If no image data found, try to get text data (could be file path)
366− var textData []byte
367− textData, err = readClipboard(clipboardFormatText)
368− if err != nil || len(textData) == 0 {
369− // If clipboard is empty, show a warning
370− return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.")
371− }
372−
373− // Check if the text data is a file path
374− textStr := string(textData)
375− // First, try to interpret as a file path (existing functionality)
376− path := strings.ReplaceAll(textStr, "\\ ", " ")
377− path, err = filepath.Abs(strings.TrimSpace(path))
378− if err == nil {
379− isAllowedType := false
380− for _, ext := range filepicker.AllowedTypes {
381− if strings.HasSuffix(path, ext) {
382− isAllowedType = true
383− break
384− }
385− }
386− if isAllowedType {
387− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
388− if !tooBig {
389− content, err := os.ReadFile(path)
390− if err == nil {
391− mimeBufferSize := min(512, len(content))
392− mimeType := http.DetectContentType(content[:mimeBufferSize])
393− fileName := filepath.Base(path)
394− attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
395− return m, util.CmdHandler(filepicker.FilePickedMsg{
396− Attachment: attachment,
397− })
398− }
399− }
400− }
401− }
402−
403− // If not a valid file path, show a warning
404− return m, util.ReportWarn("No image found in clipboard")
405− } else {
406− // We have image data from the clipboard
407− // Create a temporary file to store the clipboard image data
408− tempFile, err := os.CreateTemp("", "clipboard_image_crush_*")
409− if err != nil {
410− return m, util.ReportError(err)
411− }
412− defer tempFile.Close()
413−
414− // Write clipboard content to the temporary file
415− _, err = tempFile.Write(imageData)
416− if err != nil {
417− return m, util.ReportError(err)
418− }
419−
420− // Determine the file extension based on the image data
421− mimeBufferSize := min(512, len(imageData))
422− mimeType := http.DetectContentType(imageData[:mimeBufferSize])
423−
424− // Create an attachment from the temporary file
425− fileName := filepath.Base(tempFile.Name())
426− attachment := message.Attachment{
427− FilePath: tempFile.Name(),
428− FileName: fileName,
429− MimeType: mimeType,
430− Content: imageData,
431− }
432−
433− return m, util.CmdHandler(filepicker.FilePickedMsg{
434− Attachment: attachment,
435− })
436− }
437− }
438− // Handle Enter key
439− if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
440− value := m.textarea.Value()
441− if strings.HasSuffix(value, "\\") {
442− // If the last character is a backslash, remove it and add a newline.
443− m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
444− } else {
445− // Otherwise, send the message
446− return m, m.send()
447− }
448− }
449− }
450−
451− m.textarea, cmd = m.textarea.Update(msg)
452− cmds = append(cmds, cmd)
453−
454− if m.textarea.Focused() {
455− kp, ok := msg.(tea.KeyPressMsg)
456− if ok {
457− if kp.String() == "space" || m.textarea.Value() == "" {
458− m.isCompletionsOpen = false
459− m.currentQuery = ""
460− m.completionsStartIndex = 0
461− cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
462− } else {
463− word := m.textarea.Word()
464− if strings.HasPrefix(word, "@") {
465− // XXX: wont' work if editing in the middle of the field.
466− m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
467− m.currentQuery = word[1:]
468− x, y := m.completionsPosition()
469− x -= len(m.currentQuery)
470− m.isCompletionsOpen = true
471− cmds = append(cmds,
472− util.CmdHandler(completions.FilterCompletionsMsg{
473− Query: m.currentQuery,
474− Reopen: m.isCompletionsOpen,
475− X: x,
476− Y: y,
477− }),
478− )
479− } else if m.isCompletionsOpen {
480− m.isCompletionsOpen = false
481− m.currentQuery = ""
482− m.completionsStartIndex = 0
483− cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
484− }
485− }
486− }
487− }
488−
489− return m, tea.Batch(cmds...)
490−}
491−
492−func (m *editorCmp) setEditorPrompt() {
493− if m.app.Permissions.SkipRequests() {
494− m.textarea.SetPromptFunc(4, yoloPromptFunc)
495− return
496− }
497− m.textarea.SetPromptFunc(4, normalPromptFunc)
498−}
499−
500−func (m *editorCmp) completionsPosition() (int, int) {
501− cur := m.textarea.Cursor()
502− if cur == nil {
503− return m.x, m.y + 1 // adjust for padding
504− }
505− x := cur.X + m.x
506− y := cur.Y + m.y + 1 // adjust for padding
507− return x, y
508−}
509−
510−func (m *editorCmp) Cursor() *tea.Cursor {
511− cursor := m.textarea.Cursor()
512− if cursor != nil {
513− cursor.X = cursor.X + m.x + 1
514− cursor.Y = cursor.Y + m.y + 1 // adjust for padding
515− }
516− return cursor
517−}
518−
519−var readyPlaceholders = [...]string{
520− "Ready!",
521− "Ready...",
522− "Ready?",
523− "Ready for instructions",
524−}
525−
526−var workingPlaceholders = [...]string{
527− "Working!",
528− "Working...",
529− "Brrrrr...",
530− "Prrrrrrrr...",
531− "Processing...",
532− "Thinking...",
533−}
534−
535−func (m *editorCmp) randomizePlaceholders() {
536− m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
537− m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
538−}
539−
540−func (m *editorCmp) View() string {
541− t := styles.CurrentTheme()
542− // Update placeholder
543− if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
544− m.textarea.Placeholder = m.workingPlaceholder
545− } else {
546− m.textarea.Placeholder = m.readyPlaceholder
547− }
548− if m.app.Permissions.SkipRequests() {
549− m.textarea.Placeholder = "Yolo mode!"
550− }
551− if len(m.attachments) == 0 {
552− return t.S().Base.Padding(1).Render(
553− m.textarea.View(),
554− )
555− }
556− return t.S().Base.Padding(0, 1, 1, 1).Render(
557− lipgloss.JoinVertical(
558− lipgloss.Top,
559− m.attachmentsContent(),
560− m.textarea.View(),
561− ),
562− )
563−}
564−
565−func (m *editorCmp) SetSize(width, height int) tea.Cmd {
566− m.width = width
567− m.height = height
568− m.textarea.SetWidth(width - 2) // adjust for padding
569− m.textarea.SetHeight(height - 2) // adjust for padding
570− return nil
571−}
572−
573−func (m *editorCmp) GetSize() (int, int) {
574− return m.textarea.Width(), m.textarea.Height()
575−}
576−
577−func (m *editorCmp) attachmentsContent() string {
578− var styledAttachments []string
579− t := styles.CurrentTheme()
580− attachmentStyle := t.S().Base.
581− Padding(0, 1).
582− MarginRight(1).
583− Background(t.FgMuted).
584− Foreground(t.FgBase).
585− Render
586− iconStyle := t.S().Base.
587− Foreground(t.BgSubtle).
588− Background(t.Green).
589− Padding(0, 1).
590− Bold(true).
591− Render
592− rmStyle := t.S().Base.
593− Padding(0, 1).
594− Bold(true).
595− Background(t.Red).
596− Foreground(t.FgBase).
597− Render
598− for i, attachment := range m.attachments {
599− filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
600− icon := styles.ImageIcon
601− if attachment.IsText() {
602− icon = styles.TextIcon
603− }
604− if m.deleteMode {
605− styledAttachments = append(
606− styledAttachments,
607− rmStyle(fmt.Sprintf("%d", i)),
608− attachmentStyle(filename),
609− )
610− continue
611− }
612− styledAttachments = append(
613− styledAttachments,
614− iconStyle(icon),
615− attachmentStyle(filename),
616− )
617− }
618− return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
619−}
620−
621−func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
622− m.x = x
623− m.y = y
624− return nil
625−}
626−
627−func (m *editorCmp) startCompletions() tea.Msg {
628− ls := m.app.Config().Options.TUI.Completions
629− depth, limit := ls.Limits()
630− files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
631− slices.Sort(files)
632− completionItems := make([]completions.Completion, 0, len(files))
633− for _, file := range files {
634− file = strings.TrimPrefix(file, "./")
635− completionItems = append(completionItems, completions.Completion{
636− Title: file,
637− Value: FileCompletionItem{
638− Path: file,
639− },
640− })
641− }
642−
643− x, y := m.completionsPosition()
644− return completions.OpenCompletionsMsg{
645− Completions: completionItems,
646− X: x,
647− Y: y,
648− MaxResults: maxFileResults,
649− }
650−}
651−
652−// Blur implements Container.
653−func (c *editorCmp) Blur() tea.Cmd {
654− c.textarea.Blur()
655− return nil
656−}
657−
658−// Focus implements Container.
659−func (c *editorCmp) Focus() tea.Cmd {
660− return c.textarea.Focus()
661−}
662−
663−// IsFocused implements Container.
664−func (c *editorCmp) IsFocused() bool {
665− return c.textarea.Focused()
666−}
667−
668−// Bindings implements Container.
669−func (c *editorCmp) Bindings() []key.Binding {
670− return c.keyMap.KeyBindings()
671−}
672−
673−// TODO: most likely we do not need to have the session here
674−// we need to move some functionality to the page level
675−func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
676− c.session = session
677− for _, path := range c.sessionFileReads {
678− c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
679− }
680− return nil
681−}
682−
683−func (c *editorCmp) IsCompletionsOpen() bool {
684− return c.isCompletionsOpen
685−}
686−
687−func (c *editorCmp) HasAttachments() bool {
688− return len(c.attachments) > 0
689−}
690−
691−func (c *editorCmp) IsEmpty() bool {
692− return strings.TrimSpace(c.textarea.Value()) == ""
693−}
694−
695−func normalPromptFunc(info textarea.PromptInfo) string {
696− t := styles.CurrentTheme()
697− if info.LineNumber == 0 {
698− if info.Focused {
699− return " > "
700− }
701− return "::: "
702− }
703− if info.Focused {
704− return t.S().Base.Foreground(t.GreenDark).Render("::: ")
705− }
706− return t.S().Muted.Render("::: ")
707−}
708−
709−func yoloPromptFunc(info textarea.PromptInfo) string {
710− t := styles.CurrentTheme()
711− if info.LineNumber == 0 {
712− if info.Focused {
713− return fmt.Sprintf("%s ", t.YoloIconFocused)
714− } else {
715− return fmt.Sprintf("%s ", t.YoloIconBlurred)
716− }
717− }
718− if info.Focused {
719− return fmt.Sprintf("%s ", t.YoloDotsFocused)
720− }
721− return fmt.Sprintf("%s ", t.YoloDotsBlurred)
722−}
723−
724−func New(app *app.App) Editor {
725− t := styles.CurrentTheme()
726− ta := textarea.New()
727− ta.SetStyles(t.S().TextArea)
728− ta.ShowLineNumbers = false
729− ta.CharLimit = -1
730− ta.SetVirtualCursor(false)
731− ta.Focus()
732− e := &editorCmp{
733− // TODO: remove the app instance from here
734− app: app,
735− textarea: ta,
736− keyMap: DefaultEditorKeyMap(),
737− }
738− e.setEditorPrompt()
739−
740− e.randomizePlaceholders()
741− e.textarea.Placeholder = e.readyPlaceholder
742−
743− return e
744−}
745−
746−var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
747−
748−var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
749−
750−func (m *editorCmp) pasteIdx() int {
751− result := 0
752− for _, at := range m.attachments {
753− found := pasteRE.FindStringSubmatch(at.FileName)
754− if len(found) == 0 {
755− continue
756− }
757− idx, err := strconv.Atoi(found[1])
758− if err == nil {
759− result = max(result, idx)
760− }
761− }
762− return result + 1
763−}
764−
765−func filepathToFile(name string) ([]byte, string, error) {
766− path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
767− if err != nil {
768− return nil, "", err
769− }
770− content, err := os.ReadFile(path)
771− if err != nil {
772− return nil, "", err
773− }
774− return content, path, nil
775−}
776−
777−func mimeOf(content []byte) string {
778− mimeBufferSize := min(512, len(content))
779− return http.DetectContentType(content[:mimeBufferSize])
780−}
internal/tui/components/chat/editor/keys.godeleted−77
@@ -1,77 +0,0 @@
1−package editor
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type EditorKeyMap struct {
8− AddFile key.Binding
9− SendMessage key.Binding
10− OpenEditor key.Binding
11− Newline key.Binding
12− PasteImage key.Binding
13−}
14−
15−func DefaultEditorKeyMap() EditorKeyMap {
16− return EditorKeyMap{
17− AddFile: key.NewBinding(
18− key.WithKeys("/"),
19− key.WithHelp("/", "add file"),
20− ),
21− SendMessage: key.NewBinding(
22− key.WithKeys("enter"),
23− key.WithHelp("enter", "send"),
24− ),
25− OpenEditor: key.NewBinding(
26− key.WithKeys("ctrl+o"),
27− key.WithHelp("ctrl+o", "open editor"),
28− ),
29− Newline: key.NewBinding(
30− key.WithKeys("shift+enter", "ctrl+j"),
31− // "ctrl+j" is a common keybinding for newline in many editors. If
32− // the terminal supports "shift+enter", we substitute the help text
33− // to reflect that.
34− key.WithHelp("ctrl+j", "newline"),
35− ),
36− PasteImage: key.NewBinding(
37− key.WithKeys("ctrl+v"),
38− key.WithHelp("ctrl+v", "paste image from clipboard"),
39− ),
40− }
41−}
42−
43−// KeyBindings implements layout.KeyMapProvider
44−func (k EditorKeyMap) KeyBindings() []key.Binding {
45− return []key.Binding{
46− k.AddFile,
47− k.SendMessage,
48− k.OpenEditor,
49− k.Newline,
50− k.PasteImage,
51− AttachmentsKeyMaps.AttachmentDeleteMode,
52− AttachmentsKeyMaps.DeleteAllAttachments,
53− AttachmentsKeyMaps.Escape,
54− }
55−}
56−
57−type DeleteAttachmentKeyMaps struct {
58− AttachmentDeleteMode key.Binding
59− Escape key.Binding
60− DeleteAllAttachments key.Binding
61−}
62−
63−// TODO: update this to use the new keymap concepts
64−var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
65− AttachmentDeleteMode: key.NewBinding(
66− key.WithKeys("ctrl+r"),
67− key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
68− ),
69− Escape: key.NewBinding(
70− key.WithKeys("esc", "alt+esc"),
71− key.WithHelp("esc", "cancel delete mode"),
72− ),
73− DeleteAllAttachments: key.NewBinding(
74− key.WithKeys("r"),
75− key.WithHelp("ctrl+r+r", "delete all attachments"),
76− ),
77−}
internal/tui/components/chat/header/header.godeleted−160
@@ -1,160 +0,0 @@
1−package header
2−
3−import (
4− "fmt"
5− "strings"
6−
7− tea "charm.land/bubbletea/v2"
8− "charm.land/lipgloss/v2"
9− "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/csync"
11− "github.com/charmbracelet/crush/internal/fsext"
12− "github.com/charmbracelet/crush/internal/lsp"
13− "github.com/charmbracelet/crush/internal/pubsub"
14− "github.com/charmbracelet/crush/internal/session"
15− "github.com/charmbracelet/crush/internal/tui/styles"
16− "github.com/charmbracelet/crush/internal/tui/util"
17− "github.com/charmbracelet/x/ansi"
18−)
19−
20−type Header interface {
21− util.Model
22− SetSession(session session.Session) tea.Cmd
23− SetWidth(width int) tea.Cmd
24− SetDetailsOpen(open bool)
25− ShowingDetails() bool
26−}
27−
28−type header struct {
29− width int
30− session session.Session
31− lspClients *csync.Map[string, *lsp.Client]
32− detailsOpen bool
33−}
34−
35−func New(lspClients *csync.Map[string, *lsp.Client]) Header {
36− return &header{
37− lspClients: lspClients,
38− width: 0,
39− }
40−}
41−
42−func (h *header) Init() tea.Cmd {
43− return nil
44−}
45−
46−func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) {
47− switch msg := msg.(type) {
48− case pubsub.Event[session.Session]:
49− if msg.Type == pubsub.UpdatedEvent {
50− if h.session.ID == msg.Payload.ID {
51− h.session = msg.Payload
52− }
53− }
54− }
55− return h, nil
56−}
57−
58−func (h *header) View() string {
59− if h.session.ID == "" {
60− return ""
61− }
62−
63− const (
64− gap = " "
65− diag = "╱"
66− minDiags = 3
67− leftPadding = 1
68− rightPadding = 1
69− )
70−
71− t := styles.CurrentTheme()
72−
73− var b strings.Builder
74−
75− b.WriteString(t.S().Base.Foreground(t.Secondary).Render("Charm™"))
76− b.WriteString(gap)
77− b.WriteString(styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary))
78− b.WriteString(gap)
79−
80− availDetailWidth := h.width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minDiags
81− details := h.details(availDetailWidth)
82−
83− remainingWidth := h.width -
84− lipgloss.Width(b.String()) -
85− lipgloss.Width(details) -
86− leftPadding -
87− rightPadding
88−
89− if remainingWidth > 0 {
90− b.WriteString(t.S().Base.Foreground(t.Primary).Render(
91− strings.Repeat(diag, max(minDiags, remainingWidth)),
92− ))
93− b.WriteString(gap)
94− }
95−
96− b.WriteString(details)
97−
98− return t.S().Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
99−}
100−
101−func (h *header) details(availWidth int) string {
102− s := styles.CurrentTheme().S()
103−
104− var parts []string
105−
106− errorCount := 0
107− for l := range h.lspClients.Seq() {
108− errorCount += l.GetDiagnosticCounts().Error
109− }
110−
111− if errorCount > 0 {
112− parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
113− }
114−
115− agentCfg := config.Get().Agents[config.AgentCoder]
116− model := config.Get().GetModelByType(agentCfg.Model)
117− percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
118− formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
119− parts = append(parts, formattedPercentage)
120−
121− const keystroke = "ctrl+d"
122− if h.detailsOpen {
123− parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" close"))
124− } else {
125− parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" open "))
126− }
127−
128− dot := s.Subtle.Render(" • ")
129− metadata := strings.Join(parts, dot)
130− metadata = dot + metadata
131−
132− // Truncate cwd if necessary, and insert it at the beginning.
133− const dirTrimLimit = 4
134− cwd := fsext.DirTrim(fsext.PrettyPath(config.Get().WorkingDir()), dirTrimLimit)
135− cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
136− cwd = s.Muted.Render(cwd)
137−
138− return cwd + metadata
139−}
140−
141−func (h *header) SetDetailsOpen(open bool) {
142− h.detailsOpen = open
143−}
144−
145−// SetSession implements Header.
146−func (h *header) SetSession(session session.Session) tea.Cmd {
147− h.session = session
148− return nil
149−}
150−
151−// SetWidth implements Header.
152−func (h *header) SetWidth(width int) tea.Cmd {
153− h.width = width
154− return nil
155−}
156−
157−// ShowingDetails implements Header.
158−func (h *header) ShowingDetails() bool {
159− return h.detailsOpen
160−}
internal/tui/components/chat/messages/messages.godeleted−461
@@ -1,461 +0,0 @@
1−package messages
2−
3−import (
4− "fmt"
5− "path/filepath"
6− "strings"
7− "time"
8−
9− "charm.land/bubbles/v2/key"
10− "charm.land/bubbles/v2/viewport"
11− tea "charm.land/bubbletea/v2"
12− "charm.land/catwalk/pkg/catwalk"
13− "charm.land/lipgloss/v2"
14− "github.com/charmbracelet/x/ansi"
15− "github.com/charmbracelet/x/exp/ordered"
16− "github.com/google/uuid"
17−
18− "github.com/atotto/clipboard"
19− "github.com/charmbracelet/crush/internal/config"
20− "github.com/charmbracelet/crush/internal/message"
21− "github.com/charmbracelet/crush/internal/tui/components/anim"
22− "github.com/charmbracelet/crush/internal/tui/components/core"
23− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
24− "github.com/charmbracelet/crush/internal/tui/exp/list"
25− "github.com/charmbracelet/crush/internal/tui/styles"
26− "github.com/charmbracelet/crush/internal/tui/util"
27−)
28−
29−// CopyKey is the key binding for copying message content to the clipboard.
30−var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
31−
32−// ClearSelectionKey is the key binding for clearing the current selection in the chat interface.
33−var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection"))
34−
35−// MessageCmp defines the interface for message components in the chat interface.
36−// It combines standard UI model interfaces with message-specific functionality.
37−type MessageCmp interface {
38− util.Model // Basic Bubble util.Model interface
39− layout.Sizeable // Width/height management
40− layout.Focusable // Focus state management
41− GetMessage() message.Message // Access to underlying message data
42− SetMessage(msg message.Message) // Update the message content
43− Spinning() bool // Animation state for loading messages
44− ID() string
45−}
46−
47−// messageCmp implements the MessageCmp interface for displaying chat messages.
48−// It handles rendering of user and assistant messages with proper styling,
49−// animations, and state management.
50−type messageCmp struct {
51− width int // Component width for text wrapping
52− focused bool // Focus state for border styling
53−
54− // Core message data and state
55− message message.Message // The underlying message content
56− spinning bool // Whether to show loading animation
57− anim *anim.Anim // Animation component for loading states
58−
59− // Thinking viewport for displaying reasoning content
60− thinkingViewport viewport.Model
61−}
62−
63−var focusedMessageBorder = lipgloss.Border{
64− Left: "▌",
65−}
66−
67−// NewMessageCmp creates a new message component with the given message and options
68−func NewMessageCmp(msg message.Message) MessageCmp {
69− t := styles.CurrentTheme()
70−
71− thinkingViewport := viewport.New()
72− thinkingViewport.SetHeight(1)
73− thinkingViewport.KeyMap = viewport.KeyMap{}
74−
75− m := &messageCmp{
76− message: msg,
77− anim: anim.New(anim.Settings{
78− Size: 15,
79− GradColorA: t.Primary,
80− GradColorB: t.Secondary,
81− CycleColors: true,
82− }),
83− thinkingViewport: thinkingViewport,
84− }
85− return m
86−}
87−
88−// Init initializes the message component and starts animations if needed.
89−// Returns a command to start the animation for spinning messages.
90−func (m *messageCmp) Init() tea.Cmd {
91− m.spinning = m.shouldSpin()
92− return m.anim.Init()
93−}
94−
95−// Update handles incoming messages and updates the component state.
96−// Manages animation updates for spinning messages and stops animation when appropriate.
97−func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
98− switch msg := msg.(type) {
99− case anim.StepMsg:
100− m.spinning = m.shouldSpin()
101− if m.spinning {
102− u, cmd := m.anim.Update(msg)
103− m.anim = u.(*anim.Anim)
104− return m, cmd
105− }
106− case tea.KeyPressMsg:
107− if key.Matches(msg, CopyKey) {
108− return m, tea.Sequence(
109− tea.SetClipboard(m.message.Content().Text),
110− func() tea.Msg {
111− _ = clipboard.WriteAll(m.message.Content().Text)
112− return nil
113− },
114− util.ReportInfo("Message copied to clipboard"),
115− )
116− }
117− }
118− return m, nil
119−}
120−
121−// View renders the message component based on its current state.
122−// Returns different views for spinning, user, and assistant messages.
123−func (m *messageCmp) View() string {
124− if m.spinning && m.message.ReasoningContent().Thinking == "" {
125− if m.message.IsSummaryMessage {
126− m.anim.SetLabel("Summarizing")
127− }
128− return m.style().PaddingLeft(1).Render(m.anim.View())
129− }
130− if m.message.ID != "" {
131− // this is a user or assistant message
132− switch m.message.Role {
133− case message.User:
134− return m.renderUserMessage()
135− default:
136− return m.renderAssistantMessage()
137− }
138− }
139− return m.style().Render("No message content")
140−}
141−
142−// GetMessage returns the underlying message data
143−func (m *messageCmp) GetMessage() message.Message {
144− return m.message
145−}
146−
147−func (m *messageCmp) SetMessage(msg message.Message) {
148− m.message = msg
149−}
150−
151−// textWidth calculates the available width for text content,
152−// accounting for borders and padding
153−func (m *messageCmp) textWidth() int {
154− return m.width - 2 // take into account the border and/or padding
155−}
156−
157−// style returns the lipgloss style for the message component.
158−// Applies different border colors and styles based on message role and focus state.
159−func (msg *messageCmp) style() lipgloss.Style {
160− t := styles.CurrentTheme()
161− borderStyle := lipgloss.NormalBorder()
162− if msg.focused {
163− borderStyle = focusedMessageBorder
164− }
165−
166− style := t.S().Text
167− if msg.message.Role == message.User {
168− style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
169− } else {
170− if msg.focused {
171− style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
172− } else {
173− style = style.PaddingLeft(2)
174− }
175− }
176− return style
177−}
178−
179−// renderAssistantMessage renders assistant messages with optional footer information.
180−// Shows model name, response time, and finish reason when the message is complete.
181−func (m *messageCmp) renderAssistantMessage() string {
182− t := styles.CurrentTheme()
183− parts := []string{}
184− content := strings.TrimSpace(m.message.Content().String())
185− thinking := m.message.IsThinking()
186− thinkingContent := strings.TrimSpace(m.message.ReasoningContent().Thinking)
187− finished := m.message.IsFinished()
188− finishedData := m.message.FinishPart()
189−
190− if thinking || thinkingContent != "" {
191− m.anim.SetLabel("Thinking")
192− thinkingContent = m.renderThinkingContent()
193− } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
194− // Don't render empty assistant messages with EndTurn
195− return ""
196− } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
197− content = "*Canceled*"
198− } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
199− errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
200− truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
201− title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
202− details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
203− errorContent := fmt.Sprintf("%s\n\n%s", title, details)
204− return m.style().Render(errorContent)
205− }
206−
207− if thinkingContent != "" {
208− parts = append(parts, thinkingContent)
209− }
210−
211− if content != "" {
212− if thinkingContent != "" {
213− parts = append(parts, "")
214− }
215− parts = append(parts, m.toMarkdown(content))
216− }
217−
218− joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
219− return m.style().Render(joined)
220−}
221−
222−// renderUserMessage renders user messages with file attachments. It displays
223−// message content and any attached files with appropriate icons.
224−func (m *messageCmp) renderUserMessage() string {
225− t := styles.CurrentTheme()
226− var parts []string
227−
228− if s := m.message.Content().String(); s != "" {
229− parts = append(parts, m.toMarkdown(s))
230− }
231−
232− attachmentStyle := t.S().Base.
233− Padding(0, 1).
234− MarginRight(1).
235− Background(t.FgMuted).
236− Foreground(t.FgBase).
237− Render
238− iconStyle := t.S().Base.
239− Foreground(t.BgSubtle).
240− Background(t.Green).
241− Padding(0, 1).
242− Bold(true).
243− Render
244−
245− attachments := make([]string, len(m.message.BinaryContent()))
246− for i, attachment := range m.message.BinaryContent() {
247− const maxFilenameWidth = 10
248− filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...")
249− icon := styles.ImageIcon
250− if strings.HasPrefix(attachment.MIMEType, "text/") {
251− icon = styles.TextIcon
252− }
253− attachments[i] = lipgloss.JoinHorizontal(
254− lipgloss.Left,
255− iconStyle(icon),
256− attachmentStyle(filename),
257− )
258− }
259−
260− if len(attachments) > 0 {
261− parts = append(parts, strings.Join(attachments, ""))
262− }
263−
264− joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
265− return m.style().Render(joined)
266−}
267−
268−// toMarkdown converts text content to rendered markdown using the configured renderer
269−func (m *messageCmp) toMarkdown(content string) string {
270− r := styles.GetMarkdownRenderer(m.textWidth())
271− rendered, _ := r.Render(content)
272− return strings.TrimSuffix(rendered, "\n")
273−}
274−
275−func (m *messageCmp) renderThinkingContent() string {
276− t := styles.CurrentTheme()
277− reasoningContent := m.message.ReasoningContent()
278− if strings.TrimSpace(reasoningContent.Thinking) == "" {
279− return ""
280− }
281−
282− width := m.textWidth() - 2
283− width = min(width, 120)
284−
285− renderer := styles.GetPlainMarkdownRenderer(width - 1)
286− rendered, err := renderer.Render(reasoningContent.Thinking)
287− if err != nil {
288− lines := strings.Split(reasoningContent.Thinking, "\n")
289− var content strings.Builder
290− lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
291− for i, line := range lines {
292− if line == "" {
293− continue
294− }
295− content.WriteString(lineStyle.Width(width).Render(line))
296− if i < len(lines)-1 {
297− content.WriteString("\n")
298− }
299− }
300− rendered = content.String()
301− }
302−
303− fullContent := strings.TrimSpace(rendered)
304− height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
305− m.thinkingViewport.SetHeight(height)
306− m.thinkingViewport.SetWidth(m.textWidth())
307− m.thinkingViewport.SetContent(fullContent)
308− m.thinkingViewport.GotoBottom()
309− finishReason := m.message.FinishPart()
310− var footer string
311− if reasoningContent.StartedAt > 0 {
312− duration := m.message.ThinkingDuration()
313− if reasoningContent.FinishedAt > 0 {
314− m.anim.SetLabel("")
315− opts := core.StatusOpts{
316− Title: "Thought for",
317− Description: duration.String(),
318− }
319− if duration.String() != "0s" {
320− footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
321− }
322− } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
323− footer = t.S().Base.PaddingLeft(1).Render(m.toMarkdown("*Canceled*"))
324− } else {
325− footer = m.anim.View()
326− }
327− }
328− lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
329− result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View())
330− if footer != "" {
331− result += "\n\n" + footer
332− }
333− return result
334−}
335−
336−// shouldSpin determines whether the message should show a loading animation.
337−// Only assistant messages without content that aren't finished should spin.
338−func (m *messageCmp) shouldSpin() bool {
339− if m.message.Role != message.Assistant {
340− return false
341− }
342−
343− if m.message.IsFinished() {
344− return false
345− }
346−
347− if strings.TrimSpace(m.message.Content().Text) != "" {
348− return false
349− }
350− if len(m.message.ToolCalls()) > 0 {
351− return false
352− }
353− return true
354−}
355−
356−// Blur removes focus from the message component
357−func (m *messageCmp) Blur() tea.Cmd {
358− m.focused = false
359− return nil
360−}
361−
362−// Focus sets focus on the message component
363−func (m *messageCmp) Focus() tea.Cmd {
364− m.focused = true
365− return nil
366−}
367−
368−// IsFocused returns whether the message component is currently focused
369−func (m *messageCmp) IsFocused() bool {
370− return m.focused
371−}
372−
373−// Size management methods
374−
375−// GetSize returns the current dimensions of the message component
376−func (m *messageCmp) GetSize() (int, int) {
377− return m.width, 0
378−}
379−
380−// SetSize updates the width of the message component for text wrapping
381−func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
382− m.width = ordered.Clamp(width, 1, 120)
383− m.thinkingViewport.SetWidth(m.width - 4)
384− return nil
385−}
386−
387−// Spinning returns whether the message is currently showing a loading animation
388−func (m *messageCmp) Spinning() bool {
389− return m.spinning
390−}
391−
392−type AssistantSection interface {
393− list.Item
394− layout.Sizeable
395−}
396−type assistantSectionModel struct {
397− width int
398− id string
399− message message.Message
400− lastUserMessageTime time.Time
401−}
402−
403−// ID implements AssistantSection.
404−func (m *assistantSectionModel) ID() string {
405− return m.id
406−}
407−
408−func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
409− return &assistantSectionModel{
410− width: 0,
411− id: uuid.NewString(),
412− message: message,
413− lastUserMessageTime: lastUserMessageTime,
414− }
415−}
416−
417−func (m *assistantSectionModel) Init() tea.Cmd {
418− return nil
419−}
420−
421−func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
422− return m, nil
423−}
424−
425−func (m *assistantSectionModel) View() string {
426− t := styles.CurrentTheme()
427− finishData := m.message.FinishPart()
428− finishTime := time.Unix(finishData.Time, 0)
429− duration := finishTime.Sub(m.lastUserMessageTime)
430− infoMsg := t.S().Subtle.Render(duration.String())
431− icon := t.S().Subtle.Render(styles.ModelIcon)
432− model := config.Get().GetModel(m.message.Provider, m.message.Model)
433− if model == nil {
434− // This means the model is not configured anymore
435− model = &catwalk.Model{
436− Name: "Unknown Model",
437− }
438− }
439− modelFormatted := t.S().Muted.Render(model.Name)
440− assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
441− return t.S().Base.PaddingLeft(2).Render(
442− core.Section(assistant, m.width-2),
443− )
444−}
445−
446−func (m *assistantSectionModel) GetSize() (int, int) {
447− return m.width, 1
448−}
449−
450−func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
451− m.width = width
452− return nil
453−}
454−
455−func (m *assistantSectionModel) IsSectionHeader() bool {
456− return true
457−}
458−
459−func (m *messageCmp) ID() string {
460− return m.message.ID
461−}
internal/tui/components/chat/messages/renderer.godeleted−1403
@@ -1,1403 +0,0 @@
1−package messages
2−
3−import (
4− "cmp"
5− "encoding/json"
6− "fmt"
7− "strings"
8− "time"
9−
10− "charm.land/lipgloss/v2"
11− "charm.land/lipgloss/v2/tree"
12− "github.com/charmbracelet/crush/internal/agent"
13− "github.com/charmbracelet/crush/internal/agent/tools"
14− "github.com/charmbracelet/crush/internal/ansiext"
15− "github.com/charmbracelet/crush/internal/fsext"
16− "github.com/charmbracelet/crush/internal/tui/components/chat/todos"
17− "github.com/charmbracelet/crush/internal/tui/components/core"
18− "github.com/charmbracelet/crush/internal/tui/highlight"
19− "github.com/charmbracelet/crush/internal/tui/styles"
20− "github.com/charmbracelet/x/ansi"
21−)
22−
23−// responseContextHeight limits the number of lines displayed in tool output
24−const responseContextHeight = 10
25−
26−// renderer defines the interface for tool-specific rendering implementations
27−type renderer interface {
28− // Render returns the complete (already styled) tool‑call view, not
29− // including the outer border.
30− Render(v *toolCallCmp) string
31−}
32−
33−// rendererFactory creates new renderer instances
34−type rendererFactory func() renderer
35−
36−// renderRegistry manages the mapping of tool names to their renderers
37−type renderRegistry map[string]rendererFactory
38−
39−// register adds a new renderer factory to the registry
40−func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
41−
42−// lookup retrieves a renderer for the given tool name, falling back to generic renderer
43−func (rr renderRegistry) lookup(name string) renderer {
44− if f, ok := rr[name]; ok {
45− return f()
46− }
47− return genericRenderer{} // sensible fallback
48−}
49−
50−// registry holds all registered tool renderers
51−var registry = renderRegistry{}
52−
53−// baseRenderer provides common functionality for all tool renderers
54−type baseRenderer struct{}
55−
56−func (br baseRenderer) Render(v *toolCallCmp) string {
57− if v.result.Data != "" {
58− if strings.HasPrefix(v.result.MIMEType, "image/") {
59− return br.renderWithParams(v, v.call.Name, nil, func() string {
60− return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
61− })
62− }
63− return br.renderWithParams(v, v.call.Name, nil, func() string {
64− return renderMediaContent(v, v.result.MIMEType, v.result.Content)
65− })
66− }
67−
68− return br.renderWithParams(v, v.call.Name, nil, func() string {
69− return renderPlainContent(v, v.result.Content)
70− })
71−}
72−
73−// paramBuilder helps construct parameter lists for tool headers
74−type paramBuilder struct {
75− args []string
76−}
77−
78−// newParamBuilder creates a new parameter builder
79−func newParamBuilder() *paramBuilder {
80− return &paramBuilder{args: make([]string, 0)}
81−}
82−
83−// addMain adds the main parameter (first argument)
84−func (pb *paramBuilder) addMain(value string) *paramBuilder {
85− if value != "" {
86− pb.args = append(pb.args, value)
87− }
88− return pb
89−}
90−
91−// addKeyValue adds a key-value pair parameter
92−func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
93− if value != "" {
94− pb.args = append(pb.args, key, value)
95− }
96− return pb
97−}
98−
99−// addFlag adds a boolean flag parameter
100−func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
101− if value {
102− pb.args = append(pb.args, key, "true")
103− }
104− return pb
105−}
106−
107−// build returns the final parameter list
108−func (pb *paramBuilder) build() []string {
109− return pb.args
110−}
111−
112−// renderWithParams provides a common rendering pattern for tools with parameters
113−func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
114− width := v.textWidth()
115− if v.isNested {
116− width -= 4 // Adjust for nested tool call indentation
117− }
118− header := br.makeHeader(v, toolName, width, args...)
119− if v.isNested {
120− return v.style().Render(header)
121− }
122− if res, done := earlyState(header, v); done {
123− return res
124− }
125− body := contentRenderer()
126− return joinHeaderBody(header, body)
127−}
128−
129−// unmarshalParams safely unmarshal JSON parameters
130−func (br baseRenderer) unmarshalParams(input string, target any) error {
131− return json.Unmarshal([]byte(input), target)
132−}
133−
134−// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
135−func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
136− t := styles.CurrentTheme()
137− icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
138− if v.result.ToolCallID != "" {
139− if v.result.IsError {
140− icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
141− } else {
142− icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
143− }
144− } else if v.cancelled {
145− icon = t.S().Muted.Render(styles.ToolPending)
146− }
147− tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool)
148− prefix := fmt.Sprintf("%s %s ", icon, tool)
149− return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...)
150−}
151−
152−// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
153−func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
154− if v.isNested {
155− return br.makeNestedHeader(v, tool, width, params...)
156− }
157− t := styles.CurrentTheme()
158− icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
159− if v.result.ToolCallID != "" {
160− if v.result.IsError {
161− icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
162− } else {
163− icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
164− }
165− } else if v.cancelled {
166− icon = t.S().Muted.Render(styles.ToolPending)
167− }
168− tool = t.S().Base.Foreground(t.Blue).Render(tool)
169− prefix := fmt.Sprintf("%s %s ", icon, tool)
170− return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
171−}
172−
173−// renderError provides consistent error rendering
174−func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
175− t := styles.CurrentTheme()
176− header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
177− errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
178− message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
179− return joinHeaderBody(header, errorTag+" "+message)
180−}
181−
182−// Register tool renderers
183−func init() {
184− registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
185− registry.register(tools.JobOutputToolName, func() renderer { return bashOutputRenderer{} })
186− registry.register(tools.JobKillToolName, func() renderer { return bashKillRenderer{} })
187− registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
188− registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
189− registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
190− registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
191− registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
192− registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} })
193− registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} })
194− registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} })
195− registry.register(tools.WebSearchToolName, func() renderer { return webSearchRenderer{} })
196− registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
197− registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
198− registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
199− registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
200− registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
201− registry.register(tools.TodosToolName, func() renderer { return todosRenderer{} })
202− registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
203−}
204−
205−// -----------------------------------------------------------------------------
206−// Generic renderer
207−// -----------------------------------------------------------------------------
208−
209−// genericRenderer handles unknown tool types with basic parameter display
210−type genericRenderer struct {
211− baseRenderer
212−}
213−
214−func (gr genericRenderer) Render(v *toolCallCmp) string {
215− if v.result.Data != "" {
216− if strings.HasPrefix(v.result.MIMEType, "image/") {
217− return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
218− return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
219− })
220− }
221− return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
222− return renderMediaContent(v, v.result.MIMEType, v.result.Content)
223− })
224− }
225−
226− return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
227− return renderPlainContent(v, v.result.Content)
228− })
229−}
230−
231−// -----------------------------------------------------------------------------
232−// Bash renderer
233−// -----------------------------------------------------------------------------
234−
235−// bashRenderer handles bash command execution display
236−type bashRenderer struct {
237− baseRenderer
238−}
239−
240−// Render displays the bash command with sanitized newlines and plain output
241−func (br bashRenderer) Render(v *toolCallCmp) string {
242− var params tools.BashParams
243− if err := br.unmarshalParams(v.call.Input, &params); err != nil {
244− return br.renderError(v, "Invalid bash parameters")
245− }
246−
247− cmd := strings.ReplaceAll(params.Command, "\n", " ")
248− cmd = strings.ReplaceAll(cmd, "\t", " ")
249− args := newParamBuilder().
250− addMain(cmd).
251− addFlag("background", params.RunInBackground).
252− build()
253− if v.call.Finished {
254− var meta tools.BashResponseMetadata
255− _ = br.unmarshalParams(v.result.Metadata, &meta)
256− if meta.Background {
257− description := cmp.Or(meta.Description, params.Command)
258− width := v.textWidth()
259− if v.isNested {
260− width -= 4 // Adjust for nested tool call indentation
261− }
262− header := makeJobHeader(v, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
263− if v.isNested {
264− return v.style().Render(header)
265− }
266− if res, done := earlyState(header, v); done {
267− return res
268− }
269− content := "Command: " + params.Command + "\n" + v.result.Content
270− body := renderPlainContent(v, content)
271− return joinHeaderBody(header, body)
272− }
273− }
274−
275− return br.renderWithParams(v, "Bash", args, func() string {
276− var meta tools.BashResponseMetadata
277− if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil {
278− return renderPlainContent(v, v.result.Content)
279− }
280− // for backwards compatibility with older tool calls.
281− if meta.Output == "" && v.result.Content != tools.BashNoOutput {
282− meta.Output = v.result.Content
283− }
284−
285− if meta.Output == "" {
286− return ""
287− }
288− return renderPlainContent(v, meta.Output)
289− })
290−}
291−
292−// -----------------------------------------------------------------------------
293−// Bash Output renderer
294−// -----------------------------------------------------------------------------
295−
296−func makeJobHeader(v *toolCallCmp, subcommand, pid, description string, width int) string {
297− t := styles.CurrentTheme()
298− icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
299− if v.result.ToolCallID != "" {
300− if v.result.IsError {
301− icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
302− } else {
303− icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
304− }
305− } else if v.cancelled {
306− icon = t.S().Muted.Render(styles.ToolPending)
307− }
308−
309− jobPart := t.S().Base.Foreground(t.Blue).Render("Job")
310− subcommandPart := t.S().Base.Foreground(t.BlueDark).Render("(" + subcommand + ")")
311− pidPart := t.S().Muted.Render(pid)
312− descPart := ""
313− if description != "" {
314− descPart = " " + t.S().Subtle.Render(description)
315− }
316−
317− // Build the complete header
318− prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, subcommandPart, pidPart)
319− fullHeader := prefix + descPart
320−
321− // Truncate if needed
322− if lipgloss.Width(fullHeader) > width {
323− availableWidth := width - lipgloss.Width(prefix) - 1 // -1 for space
324− if availableWidth < 10 {
325− // Not enough space for description, just show prefix
326− return prefix
327− }
328− descPart = " " + t.S().Subtle.Render(ansi.Truncate(description, availableWidth, "…"))
329− fullHeader = prefix + descPart
330− }
331−
332− return fullHeader
333−}
334−
335−// bashOutputRenderer handles bash output retrieval display
336−type bashOutputRenderer struct {
337− baseRenderer
338−}
339−
340−// Render displays the shell ID and output from a background shell
341−func (bor bashOutputRenderer) Render(v *toolCallCmp) string {
342− var params tools.JobOutputParams
343− if err := bor.unmarshalParams(v.call.Input, &params); err != nil {
344− return bor.renderError(v, "Invalid job_output parameters")
345− }
346−
347− var meta tools.JobOutputResponseMetadata
348− var description string
349− if v.result.Metadata != "" {
350− if err := bor.unmarshalParams(v.result.Metadata, &meta); err == nil {
351− if meta.Description != "" {
352− description = meta.Description
353− } else {
354− description = meta.Command
355− }
356− }
357− }
358−
359− width := v.textWidth()
360− if v.isNested {
361− width -= 4 // Adjust for nested tool call indentation
362− }
363− header := makeJobHeader(v, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
364− if v.isNested {
365− return v.style().Render(header)
366− }
367− if res, done := earlyState(header, v); done {
368− return res
369− }
370− body := renderPlainContent(v, v.result.Content)
371− return joinHeaderBody(header, body)
372−}
373−
374−// -----------------------------------------------------------------------------
375−// Bash Kill renderer
376−// -----------------------------------------------------------------------------
377−
378−// bashKillRenderer handles bash process termination display
379−type bashKillRenderer struct {
380− baseRenderer
381−}
382−
383−// Render displays the shell ID being terminated
384−func (bkr bashKillRenderer) Render(v *toolCallCmp) string {
385− var params tools.JobKillParams
386− if err := bkr.unmarshalParams(v.call.Input, &params); err != nil {
387− return bkr.renderError(v, "Invalid job_kill parameters")
388− }
389−
390− var meta tools.JobKillResponseMetadata
391− var description string
392− if v.result.Metadata != "" {
393− if err := bkr.unmarshalParams(v.result.Metadata, &meta); err == nil {
394− if meta.Description != "" {
395− description = meta.Description
396− } else {
397− description = meta.Command
398− }
399− }
400− }
401−
402− width := v.textWidth()
403− if v.isNested {
404− width -= 4 // Adjust for nested tool call indentation
405− }
406− header := makeJobHeader(v, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
407− if v.isNested {
408− return v.style().Render(header)
409− }
410− if res, done := earlyState(header, v); done {
411− return res
412− }
413− body := renderPlainContent(v, v.result.Content)
414− return joinHeaderBody(header, body)
415−}
416−
417−// -----------------------------------------------------------------------------
418−// View renderer
419−// -----------------------------------------------------------------------------
420−
421−// viewRenderer handles file viewing with syntax highlighting and line numbers
422−type viewRenderer struct {
423− baseRenderer
424−}
425−
426−// Render displays file content with optional limit and offset parameters
427−func (vr viewRenderer) Render(v *toolCallCmp) string {
428− var params tools.ViewParams
429− if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
430− return vr.renderError(v, "Invalid view parameters")
431− }
432−
433− file := fsext.PrettyPath(params.FilePath)
434− args := newParamBuilder().
435− addMain(file).
436− addKeyValue("limit", formatNonZero(params.Limit)).
437− addKeyValue("offset", formatNonZero(params.Offset)).
438− build()
439−
440− return vr.renderWithParams(v, "View", args, func() string {
441− if v.result.Data != "" && strings.HasPrefix(v.result.MIMEType, "image/") {
442− return renderImageContent(v, v.result.Data, v.result.MIMEType, "")
443− }
444−
445− var meta tools.ViewResponseMetadata
446− if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
447− return renderPlainContent(v, v.result.Content)
448− }
449− return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
450− })
451−}
452−
453−// formatNonZero returns string representation of non-zero integers, empty string for zero
454−func formatNonZero(value int) string {
455− if value == 0 {
456− return ""
457− }
458− return fmt.Sprintf("%d", value)
459−}
460−
461−// -----------------------------------------------------------------------------
462−// Edit renderer
463−// -----------------------------------------------------------------------------
464−
465−// editRenderer handles file editing with diff visualization
466−type editRenderer struct {
467− baseRenderer
468−}
469−
470−// Render displays the edited file with a formatted diff of changes
471−func (er editRenderer) Render(v *toolCallCmp) string {
472− t := styles.CurrentTheme()
473− var params tools.EditParams
474− var args []string
475− if err := er.unmarshalParams(v.call.Input, &params); err == nil {
476− file := fsext.PrettyPath(params.FilePath)
477− args = newParamBuilder().addMain(file).build()
478− }
479−
480− return er.renderWithParams(v, "Edit", args, func() string {
481− var meta tools.EditResponseMetadata
482− if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
483− return renderPlainContent(v, v.result.Content)
484− }
485−
486− formatter := core.DiffFormatter().
487− Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
488− After(fsext.PrettyPath(params.FilePath), meta.NewContent).
489− Width(v.textWidth() - 2) // -2 for padding
490− if v.textWidth() > 120 {
491− formatter = formatter.Split()
492− }
493− // add a message to the bottom if the content was truncated
494− formatted := formatter.String()
495− if lipgloss.Height(formatted) > responseContextHeight {
496− contentLines := strings.Split(formatted, "\n")
497− truncateMessage := t.S().Muted.
498− Background(t.BgBaseLighter).
499− PaddingLeft(2).
500− Width(v.textWidth() - 2).
501− Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
502− formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
503− }
504− return formatted
505− })
506−}
507−
508−// -----------------------------------------------------------------------------
509−// Multi-Edit renderer
510−// -----------------------------------------------------------------------------
511−
512−// multiEditRenderer handles multiple file edits with diff visualization
513−type multiEditRenderer struct {
514− baseRenderer
515−}
516−
517−// Render displays the multi-edited file with a formatted diff of changes
518−func (mer multiEditRenderer) Render(v *toolCallCmp) string {
519− t := styles.CurrentTheme()
520− var params tools.MultiEditParams
521− var args []string
522− if err := mer.unmarshalParams(v.call.Input, &params); err == nil {
523− file := fsext.PrettyPath(params.FilePath)
524− editsCount := len(params.Edits)
525− args = newParamBuilder().
526− addMain(file).
527− addKeyValue("edits", fmt.Sprintf("%d", editsCount)).
528− build()
529− }
530−
531− return mer.renderWithParams(v, "Multi-Edit", args, func() string {
532− var meta tools.MultiEditResponseMetadata
533− if err := mer.unmarshalParams(v.result.Metadata, &meta); err != nil {
534− return renderPlainContent(v, v.result.Content)
535− }
536−
537− formatter := core.DiffFormatter().
538− Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
539− After(fsext.PrettyPath(params.FilePath), meta.NewContent).
540− Width(v.textWidth() - 2) // -2 for padding
541− if v.textWidth() > 120 {
542− formatter = formatter.Split()
543− }
544− // add a message to the bottom if the content was truncated
545− formatted := formatter.String()
546− if lipgloss.Height(formatted) > responseContextHeight {
547− contentLines := strings.Split(formatted, "\n")
548− truncateMessage := t.S().Muted.
549− Background(t.BgBaseLighter).
550− PaddingLeft(2).
551− Width(v.textWidth() - 4).
552− Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
553− formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
554− }
555−
556− // Add failed edits warning if any exist
557− if len(meta.EditsFailed) > 0 {
558− noteTag := t.S().Base.Padding(0, 2).Background(t.Info).Foreground(t.White).Render("Note")
559− noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits))
560− note := t.S().Base.
561− Width(v.textWidth() - 2).
562− Render(fmt.Sprintf("%s %s", noteTag, t.S().Muted.Render(noteMsg)))
563− formatted = lipgloss.JoinVertical(lipgloss.Left, formatted, "", note)
564− }
565−
566− return formatted
567− })
568−}
569−
570−// -----------------------------------------------------------------------------
571−// Write renderer
572−// -----------------------------------------------------------------------------
573−
574−// writeRenderer handles file writing with syntax-highlighted content preview
575−type writeRenderer struct {
576− baseRenderer
577−}
578−
579−// Render displays the file being written with syntax highlighting
580−func (wr writeRenderer) Render(v *toolCallCmp) string {
581− var params tools.WriteParams
582− var args []string
583− var file string
584− if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
585− file = fsext.PrettyPath(params.FilePath)
586− args = newParamBuilder().addMain(file).build()
587− }
588−
589− return wr.renderWithParams(v, "Write", args, func() string {
590− return renderCodeContent(v, file, params.Content, 0)
591− })
592−}
593−
594−// -----------------------------------------------------------------------------
595−// Fetch renderer
596−// -----------------------------------------------------------------------------
597−
598−// simpleFetchRenderer handles URL fetching with format-specific content display
599−type simpleFetchRenderer struct {
600− baseRenderer
601−}
602−
603−// Render displays the fetched URL with format and timeout parameters
604−func (fr simpleFetchRenderer) Render(v *toolCallCmp) string {
605− var params tools.FetchParams
606− var args []string
607− if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
608− args = newParamBuilder().
609− addMain(params.URL).
610− addKeyValue("format", params.Format).
611− addKeyValue("timeout", formatTimeout(params.Timeout)).
612− build()
613− }
614−
615− return fr.renderWithParams(v, "Fetch", args, func() string {
616− file := fr.getFileExtension(params.Format)
617− return renderCodeContent(v, file, v.result.Content, 0)
618− })
619−}
620−
621−// getFileExtension returns appropriate file extension for syntax highlighting
622−func (fr simpleFetchRenderer) getFileExtension(format string) string {
623− switch format {
624− case "text":
625− return "fetch.txt"
626− case "html":
627− return "fetch.html"
628− default:
629− return "fetch.md"
630− }
631−}
632−
633−// -----------------------------------------------------------------------------
634−// Agentic fetch renderer
635−// -----------------------------------------------------------------------------
636−
637−// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls
638−type agenticFetchRenderer struct {
639− baseRenderer
640−}
641−
642−// Render displays the fetched URL or web search with prompt parameter and nested tool calls
643−func (fr agenticFetchRenderer) Render(v *toolCallCmp) string {
644− t := styles.CurrentTheme()
645− var params tools.AgenticFetchParams
646− var args []string
647− if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
648− if params.URL != "" {
649− args = newParamBuilder().
650− addMain(params.URL).
651− build()
652− }
653− }
654−
655− prompt := params.Prompt
656− prompt = strings.ReplaceAll(prompt, "\n", " ")
657−
658− header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...)
659− if res, done := earlyState(header, v); v.cancelled && done {
660− return res
661− }
662−
663− taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt")
664− remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1)
665− remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1))
666− prompt = t.S().Base.Width(remainingWidth).Render(prompt)
667− header = lipgloss.JoinVertical(
668− lipgloss.Left,
669− header,
670− "",
671− lipgloss.JoinHorizontal(
672− lipgloss.Left,
673− taskTag,
674− " ",
675− prompt,
676− ),
677− )
678− childTools := tree.Root(header)
679−
680− for _, call := range v.nestedToolCalls {
681− call.SetSize(remainingWidth, 1)
682− childTools.Child(call.View())
683− }
684− parts := []string{
685− childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
686− }
687−
688− if v.result.ToolCallID == "" {
689− v.spinning = true
690− parts = append(parts, "", v.anim.View())
691− } else {
692− v.spinning = false
693− }
694−
695− header = lipgloss.JoinVertical(
696− lipgloss.Left,
697− parts...,
698− )
699−
700− if v.result.ToolCallID == "" {
701− return header
702− }
703− body := renderMarkdownContent(v, v.result.Content)
704− return joinHeaderBody(header, body)
705−}
706−
707−// formatTimeout converts timeout seconds to duration string
708−func formatTimeout(timeout int) string {
709− if timeout == 0 {
710− return ""
711− }
712− return (time.Duration(timeout) * time.Second).String()
713−}
714−
715−// -----------------------------------------------------------------------------
716−// Web fetch renderer
717−// -----------------------------------------------------------------------------
718−
719−// webFetchRenderer handles web page fetching with simplified URL display
720−type webFetchRenderer struct {
721− baseRenderer
722−}
723−
724−// Render displays a compact view of web_fetch with just the URL in a link style
725−func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
726− var params tools.WebFetchParams
727− var args []string
728− if err := wfr.unmarshalParams(v.call.Input, &params); err == nil {
729− args = newParamBuilder().
730− addMain(params.URL).
731− build()
732− }
733−
734− return wfr.renderWithParams(v, "Fetch", args, func() string {
735− return renderMarkdownContent(v, v.result.Content)
736− })
737−}
738−
739−// -----------------------------------------------------------------------------
740−// Web search renderer
741−// -----------------------------------------------------------------------------
742−
743−// webSearchRenderer handles web search with query display
744−type webSearchRenderer struct {
745− baseRenderer
746−}
747−
748−// Render displays a compact view of web_search with just the query
749−func (wsr webSearchRenderer) Render(v *toolCallCmp) string {
750− var params tools.WebSearchParams
751− var args []string
752− if err := wsr.unmarshalParams(v.call.Input, &params); err == nil {
753− args = newParamBuilder().
754− addMain(params.Query).
755− build()
756− }
757−
758− return wsr.renderWithParams(v, "Search", args, func() string {
759− return renderMarkdownContent(v, v.result.Content)
760− })
761−}
762−
763−// -----------------------------------------------------------------------------
764−// Download renderer
765−// -----------------------------------------------------------------------------
766−
767−// downloadRenderer handles file downloading with URL and file path display
768−type downloadRenderer struct {
769− baseRenderer
770−}
771−
772−// Render displays the download URL and destination file path with timeout parameter
773−func (dr downloadRenderer) Render(v *toolCallCmp) string {
774− var params tools.DownloadParams
775− var args []string
776− if err := dr.unmarshalParams(v.call.Input, &params); err == nil {
777− args = newParamBuilder().
778− addMain(params.URL).
779− addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
780− addKeyValue("timeout", formatTimeout(params.Timeout)).
781− build()
782− }
783−
784− return dr.renderWithParams(v, "Download", args, func() string {
785− return renderPlainContent(v, v.result.Content)
786− })
787−}
788−
789−// -----------------------------------------------------------------------------
790−// Glob renderer
791−// -----------------------------------------------------------------------------
792−
793−// globRenderer handles file pattern matching with path filtering
794−type globRenderer struct {
795− baseRenderer
796−}
797−
798−// Render displays the glob pattern with optional path parameter
799−func (gr globRenderer) Render(v *toolCallCmp) string {
800− var params tools.GlobParams
801− var args []string
802− if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
803− args = newParamBuilder().
804− addMain(params.Pattern).
805− addKeyValue("path", params.Path).
806− build()
807− }
808−
809− return gr.renderWithParams(v, "Glob", args, func() string {
810− return renderPlainContent(v, v.result.Content)
811− })
812−}
813−
814−// -----------------------------------------------------------------------------
815−// Grep renderer
816−// -----------------------------------------------------------------------------
817−
818−// grepRenderer handles content searching with pattern matching options
819−type grepRenderer struct {
820− baseRenderer
821−}
822−
823−// Render displays the search pattern with path, include, and literal text options
824−func (gr grepRenderer) Render(v *toolCallCmp) string {
825− var params tools.GrepParams
826− var args []string
827− if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
828− args = newParamBuilder().
829− addMain(params.Pattern).
830− addKeyValue("path", params.Path).
831− addKeyValue("include", params.Include).
832− addFlag("literal", params.LiteralText).
833− build()
834− }
835−
836− return gr.renderWithParams(v, "Grep", args, func() string {
837− return renderPlainContent(v, v.result.Content)
838− })
839−}
840−
841−// -----------------------------------------------------------------------------
842−// LS renderer
843−// -----------------------------------------------------------------------------
844−
845−// lsRenderer handles directory listing with default path handling
846−type lsRenderer struct {
847− baseRenderer
848−}
849−
850−// Render displays the directory path, defaulting to current directory
851−func (lr lsRenderer) Render(v *toolCallCmp) string {
852− var params tools.LSParams
853− var args []string
854− if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
855− path := params.Path
856− if path == "" {
857− path = "."
858− }
859− path = fsext.PrettyPath(path)
860−
861− args = newParamBuilder().addMain(path).build()
862− }
863−
864− return lr.renderWithParams(v, "List", args, func() string {
865− return renderPlainContent(v, v.result.Content)
866− })
867−}
868−
869−// -----------------------------------------------------------------------------
870−// Sourcegraph renderer
871−// -----------------------------------------------------------------------------
872−
873−// sourcegraphRenderer handles code search with count and context options
874−type sourcegraphRenderer struct {
875− baseRenderer
876−}
877−
878−// Render displays the search query with optional count and context window parameters
879−func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
880− var params tools.SourcegraphParams
881− var args []string
882− if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
883− args = newParamBuilder().
884− addMain(params.Query).
885− addKeyValue("count", formatNonZero(params.Count)).
886− addKeyValue("context", formatNonZero(params.ContextWindow)).
887− build()
888− }
889−
890− return sr.renderWithParams(v, "Sourcegraph", args, func() string {
891− return renderPlainContent(v, v.result.Content)
892− })
893−}
894−
895−// -----------------------------------------------------------------------------
896−// Diagnostics renderer
897−// -----------------------------------------------------------------------------
898−
899−// diagnosticsRenderer handles project-wide diagnostic information
900−type diagnosticsRenderer struct {
901− baseRenderer
902−}
903−
904−// Render displays project diagnostics with plain content formatting
905−func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
906− args := newParamBuilder().addMain("project").build()
907−
908− return dr.renderWithParams(v, "Diagnostics", args, func() string {
909− return renderPlainContent(v, v.result.Content)
910− })
911−}
912−
913−// -----------------------------------------------------------------------------
914−// Task renderer
915−// -----------------------------------------------------------------------------
916−
917−// agentRenderer handles project-wide diagnostic information
918−type agentRenderer struct {
919− baseRenderer
920−}
921−
922−func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator {
923− if width == 0 {
924− width = 2
925− }
926− if lPadding == 0 {
927− lPadding = 1
928− }
929− return func(children tree.Children, index int) string {
930− line := strings.Repeat("─", width)
931− padding := strings.Repeat(" ", lPadding)
932− if children.Length()-1 == index {
933− return padding + "╰" + line
934− }
935− return padding + "├" + line
936− }
937−}
938−
939−// Render displays agent task parameters and result content
940−func (tr agentRenderer) Render(v *toolCallCmp) string {
941− t := styles.CurrentTheme()
942− var params agent.AgentParams
943− tr.unmarshalParams(v.call.Input, &params)
944−
945− prompt := params.Prompt
946− prompt = strings.ReplaceAll(prompt, "\n", " ")
947−
948− header := tr.makeHeader(v, "Agent", v.textWidth())
949− if res, done := earlyState(header, v); v.cancelled && done {
950− return res
951− }
952− taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task")
953− remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
954− remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
955− prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
956− header = lipgloss.JoinVertical(
957− lipgloss.Left,
958− header,
959− "",
960− lipgloss.JoinHorizontal(
961− lipgloss.Left,
962− taskTag,
963− " ",
964− prompt,
965− ),
966− )
967− childTools := tree.Root(header)
968−
969− for _, call := range v.nestedToolCalls {
970− call.SetSize(remainingWidth, 1)
971− childTools.Child(call.View())
972− }
973− parts := []string{
974− childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
975− }
976−
977− if v.result.ToolCallID == "" {
978− v.spinning = true
979− parts = append(parts, "", v.anim.View())
980− } else {
981− v.spinning = false
982− }
983−
984− header = lipgloss.JoinVertical(
985− lipgloss.Left,
986− parts...,
987− )
988−
989− if v.result.ToolCallID == "" {
990− return header
991− }
992−
993− body := renderMarkdownContent(v, v.result.Content)
994− return joinHeaderBody(header, body)
995−}
996−
997−// renderParamList renders params, params[0] (params[1]=params[2] ....)
998−func renderParamList(nested bool, paramsWidth int, params ...string) string {
999− t := styles.CurrentTheme()
1000− if len(params) == 0 {
1001− return ""
1002− }
1003− mainParam := params[0]
1004− if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
1005− mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
1006− }
1007−
1008− if len(params) == 1 {
1009− return t.S().Subtle.Render(mainParam)
1010− }
1011− otherParams := params[1:]
1012− // create pairs of key/value
1013− // if odd number of params, the last one is a key without value
1014− if len(otherParams)%2 != 0 {
1015− otherParams = append(otherParams, "")
1016− }
1017− parts := make([]string, 0, len(otherParams)/2)
1018− for i := 0; i < len(otherParams); i += 2 {
1019− key := otherParams[i]
1020− value := otherParams[i+1]
1021− if value == "" {
1022− continue
1023− }
1024− parts = append(parts, fmt.Sprintf("%s=%s", key, value))
1025− }
1026−
1027− partsRendered := strings.Join(parts, ", ")
1028− remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
1029− if remainingWidth < 30 {
1030− // No space for the params, just show the main
1031− return t.S().Subtle.Render(mainParam)
1032− }
1033−
1034− if len(parts) > 0 {
1035− mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
1036− }
1037−
1038− return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
1039−}
1040−
1041−// earlyState returns immediately‑rendered error/cancelled/ongoing states.
1042−func earlyState(header string, v *toolCallCmp) (string, bool) {
1043− t := styles.CurrentTheme()
1044− message := ""
1045− switch {
1046− case v.result.IsError:
1047− message = v.renderToolError()
1048− case v.cancelled:
1049− message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
1050− case v.result.ToolCallID == "":
1051− if v.permissionRequested && !v.permissionGranted {
1052− message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...")
1053− } else {
1054− message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
1055− }
1056− default:
1057− return "", false
1058− }
1059−
1060− message = t.S().Base.PaddingLeft(2).Render(message)
1061− return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
1062−}
1063−
1064−func joinHeaderBody(header, body string) string {
1065− t := styles.CurrentTheme()
1066− if body == "" {
1067− return header
1068− }
1069− body = t.S().Base.PaddingLeft(2).Render(body)
1070− return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
1071−}
1072−
1073−func renderPlainContent(v *toolCallCmp, content string) string {
1074− t := styles.CurrentTheme()
1075− content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
1076− content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces
1077− content = strings.TrimSpace(content)
1078− lines := strings.Split(content, "\n")
1079−
1080− width := v.textWidth() - 2
1081− var out []string
1082− for i, ln := range lines {
1083− if i >= responseContextHeight {
1084− break
1085− }
1086− ln = ansiext.Escape(ln)
1087− ln = " " + ln
1088− if lipgloss.Width(ln) > width {
1089− ln = v.fit(ln, width)
1090− }
1091− out = append(out, t.S().Muted.
1092− Width(width).
1093− Background(t.BgBaseLighter).
1094− Render(ln))
1095− }
1096−
1097− if len(lines) > responseContextHeight {
1098− out = append(out, t.S().Muted.
1099− Background(t.BgBaseLighter).
1100− Width(width).
1101− Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
1102− }
1103−
1104− return strings.Join(out, "\n")
1105−}
1106−
1107−func renderMarkdownContent(v *toolCallCmp, content string) string {
1108− t := styles.CurrentTheme()
1109− content = strings.ReplaceAll(content, "\r\n", "\n")
1110− content = strings.ReplaceAll(content, "\t", " ")
1111− content = strings.TrimSpace(content)
1112−
1113− width := v.textWidth() - 2
1114− width = min(width, 120)
1115−
1116− renderer := styles.GetPlainMarkdownRenderer(width)
1117− rendered, err := renderer.Render(content)
1118− if err != nil {
1119− return renderPlainContent(v, content)
1120− }
1121−
1122− lines := strings.Split(rendered, "\n")
1123−
1124− var out []string
1125− for i, ln := range lines {
1126− if i >= responseContextHeight {
1127− break
1128− }
1129− out = append(out, ln)
1130− }
1131−
1132− style := t.S().Muted.Background(t.BgBaseLighter)
1133− if len(lines) > responseContextHeight {
1134− out = append(out, style.
1135− Width(width-2).
1136− Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
1137− }
1138−
1139− return style.Render(strings.Join(out, "\n"))
1140−}
1141−
1142−func getDigits(n int) int {
1143− if n == 0 {
1144− return 1
1145− }
1146− if n < 0 {
1147− n = -n
1148− }
1149−
1150− digits := 0
1151− for n > 0 {
1152− n /= 10
1153− digits++
1154− }
1155−
1156− return digits
1157−}
1158−
1159−func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
1160− t := styles.CurrentTheme()
1161− content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
1162− content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces
1163− truncated := truncateHeight(content, responseContextHeight)
1164−
1165− lines := strings.Split(truncated, "\n")
1166− for i, ln := range lines {
1167− lines[i] = ansiext.Escape(ln)
1168− }
1169−
1170− bg := t.BgBase
1171− highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg)
1172− lines = strings.Split(highlighted, "\n")
1173−
1174− if len(strings.Split(content, "\n")) > responseContextHeight {
1175− lines = append(lines, t.S().Muted.
1176− Background(bg).
1177− Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
1178− }
1179−
1180− maxLineNumber := len(lines) + offset
1181− maxDigits := getDigits(maxLineNumber)
1182− numFmt := fmt.Sprintf("%%%dd", maxDigits)
1183− const numPR, numPL, codePR, codePL = 1, 1, 1, 2
1184− w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding
1185− for i, ln := range lines {
1186− num := t.S().Base.
1187− Foreground(t.FgMuted).
1188− Background(t.BgBase).
1189− PaddingRight(1).
1190− PaddingLeft(1).
1191− Render(fmt.Sprintf(numFmt, i+1+offset))
1192− lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
1193− num,
1194− t.S().Base.
1195− Width(w).
1196− Background(bg).
1197− PaddingRight(1).
1198− PaddingLeft(2).
1199− Render(v.fit(ln, w-codePL-codePR)),
1200− )
1201− }
1202−
1203− return lipgloss.JoinVertical(lipgloss.Left, lines...)
1204−}
1205−
1206−// renderImageContent renders image data with optional text content (for MCP tools).
1207−func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string {
1208− t := styles.CurrentTheme()
1209−
1210− dataSize := len(data) * 3 / 4
1211− sizeStr := formatSize(dataSize)
1212−
1213− loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
1214− arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
1215− typeStyled := t.S().Base.Render(mediaType)
1216− sizeStyled := t.S().Subtle.Render(sizeStr)
1217−
1218− imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
1219− if strings.TrimSpace(textContent) != "" {
1220− textDisplay := renderPlainContent(v, textContent)
1221− return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
1222− }
1223−
1224− return imageDisplay
1225−}
1226−
1227−// renderMediaContent renders non-image media content.
1228−func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string {
1229− t := styles.CurrentTheme()
1230−
1231− loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
1232− arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
1233− typeStyled := t.S().Base.Render(mediaType)
1234− mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
1235−
1236− if strings.TrimSpace(textContent) != "" {
1237− textDisplay := renderPlainContent(v, textContent)
1238− return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
1239− }
1240−
1241− return mediaDisplay
1242−}
1243−
1244−// formatSize formats byte count as human-readable size.
1245−func formatSize(bytes int) string {
1246− if bytes < 1024 {
1247− return fmt.Sprintf("%d B", bytes)
1248− }
1249− if bytes < 1024*1024 {
1250− return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
1251− }
1252− return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
1253−}
1254−
1255−func (v *toolCallCmp) renderToolError() string {
1256− t := styles.CurrentTheme()
1257− err := strings.ReplaceAll(v.result.Content, "\n", " ")
1258− errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
1259− err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
1260− return err
1261−}
1262−
1263−func truncateHeight(s string, h int) string {
1264− lines := strings.Split(s, "\n")
1265− if len(lines) > h {
1266− return strings.Join(lines[:h], "\n")
1267− }
1268− return s
1269−}
1270−
1271−func prettifyToolName(name string) string {
1272− switch name {
1273− case agent.AgentToolName:
1274− return "Agent"
1275− case tools.BashToolName:
1276− return "Bash"
1277− case tools.JobOutputToolName:
1278− return "Job: Output"
1279− case tools.JobKillToolName:
1280− return "Job: Kill"
1281− case tools.DownloadToolName:
1282− return "Download"
1283− case tools.EditToolName:
1284− return "Edit"
1285− case tools.MultiEditToolName:
1286− return "Multi-Edit"
1287− case tools.FetchToolName:
1288− return "Fetch"
1289− case tools.AgenticFetchToolName:
1290− return "Agentic Fetch"
1291− case tools.WebFetchToolName:
1292− return "Fetch"
1293− case tools.WebSearchToolName:
1294− return "Search"
1295− case tools.GlobToolName:
1296− return "Glob"
1297− case tools.GrepToolName:
1298− return "Grep"
1299− case tools.LSToolName:
1300− return "List"
1301− case tools.SourcegraphToolName:
1302− return "Sourcegraph"
1303− case tools.TodosToolName:
1304− return "To-Do"
1305− case tools.ViewToolName:
1306− return "View"
1307− case tools.WriteToolName:
1308− return "Write"
1309− default:
1310− return name
1311− }
1312−}
1313−
1314−// -----------------------------------------------------------------------------
1315−// Todos renderer
1316−// -----------------------------------------------------------------------------
1317−
1318−type todosRenderer struct {
1319− baseRenderer
1320−}
1321−
1322−func (tr todosRenderer) Render(v *toolCallCmp) string {
1323− t := styles.CurrentTheme()
1324− var params tools.TodosParams
1325− var meta tools.TodosResponseMetadata
1326− var headerText string
1327− var body string
1328−
1329− // Parse params for pending state (before result is available).
1330− if err := tr.unmarshalParams(v.call.Input, &params); err == nil {
1331− completedCount := 0
1332− inProgressTask := ""
1333− for _, todo := range params.Todos {
1334− if todo.Status == "completed" {
1335− completedCount++
1336− }
1337− if todo.Status == "in_progress" {
1338− if todo.ActiveForm != "" {
1339− inProgressTask = todo.ActiveForm
1340− } else {
1341− inProgressTask = todo.Content
1342− }
1343− }
1344− }
1345−
1346− // Default display from params (used when pending or no metadata).
1347− ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
1348− headerText = ratio
1349− if inProgressTask != "" {
1350− headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
1351− }
1352−
1353− // If we have metadata, use it for richer display.
1354− if v.result.Metadata != "" {
1355− if err := tr.unmarshalParams(v.result.Metadata, &meta); err == nil {
1356− if meta.IsNew {
1357− if meta.JustStarted != "" {
1358− headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
1359− } else {
1360− headerText = fmt.Sprintf("created %d todos", meta.Total)
1361− }
1362− body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
1363− } else {
1364− // Build header based on what changed.
1365− hasCompleted := len(meta.JustCompleted) > 0
1366− hasStarted := meta.JustStarted != ""
1367− allCompleted := meta.Completed == meta.Total
1368−
1369− ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
1370− if hasCompleted && hasStarted {
1371− text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
1372− headerText = fmt.Sprintf("%s%s", ratio, text)
1373− } else if hasCompleted {
1374− text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
1375− if allCompleted {
1376− text = t.S().Subtle.Render(" · completed all")
1377− }
1378− headerText = fmt.Sprintf("%s%s", ratio, text)
1379− } else if hasStarted {
1380− headerText = fmt.Sprintf("%s%s", ratio, t.S().Subtle.Render(" · starting task"))
1381− } else {
1382− headerText = ratio
1383− }
1384−
1385− // Build body with details.
1386− if allCompleted {
1387− // Show all todos when all are completed, like when created
1388− body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
1389− } else if meta.JustStarted != "" {
1390− body = t.S().Base.Foreground(t.GreenDark).Render(styles.ArrowRightIcon+" ") +
1391− t.S().Base.Foreground(t.FgBase).Render(meta.JustStarted)
1392− }
1393− }
1394− }
1395− }
1396− }
1397−
1398− args := newParamBuilder().addMain(headerText).build()
1399−
1400− return tr.renderWithParams(v, "To-Do", args, func() string {
1401− return body
1402− })
1403−}
internal/tui/components/chat/messages/tool.godeleted−877
@@ -1,877 +0,0 @@
1−package messages
2−
3−import (
4− "encoding/json"
5− "fmt"
6− "path/filepath"
7− "strings"
8− "time"
9−
10− "charm.land/bubbles/v2/key"
11− tea "charm.land/bubbletea/v2"
12− "charm.land/lipgloss/v2"
13− "github.com/atotto/clipboard"
14− "github.com/charmbracelet/crush/internal/agent"
15− "github.com/charmbracelet/crush/internal/agent/tools"
16− "github.com/charmbracelet/crush/internal/diff"
17− "github.com/charmbracelet/crush/internal/fsext"
18− "github.com/charmbracelet/crush/internal/message"
19− "github.com/charmbracelet/crush/internal/permission"
20− "github.com/charmbracelet/crush/internal/tui/components/anim"
21− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
22− "github.com/charmbracelet/crush/internal/tui/styles"
23− "github.com/charmbracelet/crush/internal/tui/util"
24− "github.com/charmbracelet/x/ansi"
25−)
26−
27−// ToolCallCmp defines the interface for tool call components in the chat interface.
28−// It manages the display of tool execution including pending states, results, and errors.
29−type ToolCallCmp interface {
30− util.Model // Basic Bubble util.Model interface
31− layout.Sizeable // Width/height management
32− layout.Focusable // Focus state management
33− GetToolCall() message.ToolCall // Access to tool call data
34− GetToolResult() message.ToolResult // Access to tool result data
35− SetToolResult(message.ToolResult) // Update tool result
36− SetToolCall(message.ToolCall) // Update tool call
37− SetCancelled() // Mark as cancelled
38− ParentMessageID() string // Get parent message ID
39− Spinning() bool // Animation state for pending tools
40− GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
41− SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
42− SetIsNested(bool) // Set whether this tool call is nested
43− ID() string
44− SetPermissionRequested() // Mark permission request
45− SetPermissionGranted() // Mark permission granted
46−}
47−
48−// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
49−// It handles rendering of tool execution states including pending, completed, and error states.
50−type toolCallCmp struct {
51− width int // Component width for text wrapping
52− focused bool // Focus state for border styling
53− isNested bool // Whether this tool call is nested within another
54−
55− // Tool call data and state
56− parentMessageID string // ID of the message that initiated this tool call
57− call message.ToolCall // The tool call being executed
58− result message.ToolResult // The result of the tool execution
59− cancelled bool // Whether the tool call was cancelled
60− permissionRequested bool
61− permissionGranted bool
62−
63− // Animation state for pending tool calls
64− spinning bool // Whether to show loading animation
65− anim util.Model // Animation component for pending states
66−
67− nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
68−}
69−
70−// ToolCallOption provides functional options for configuring tool call components
71−type ToolCallOption func(*toolCallCmp)
72−
73−// WithToolCallCancelled marks the tool call as cancelled
74−func WithToolCallCancelled() ToolCallOption {
75− return func(m *toolCallCmp) {
76− m.cancelled = true
77− }
78−}
79−
80−// WithToolCallResult sets the initial tool result
81−func WithToolCallResult(result message.ToolResult) ToolCallOption {
82− return func(m *toolCallCmp) {
83− m.result = result
84− }
85−}
86−
87−func WithToolCallNested(isNested bool) ToolCallOption {
88− return func(m *toolCallCmp) {
89− m.isNested = isNested
90− }
91−}
92−
93−func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
94− return func(m *toolCallCmp) {
95− m.nestedToolCalls = calls
96− }
97−}
98−
99−func WithToolPermissionRequested() ToolCallOption {
100− return func(m *toolCallCmp) {
101− m.permissionRequested = true
102− }
103−}
104−
105−func WithToolPermissionGranted() ToolCallOption {
106− return func(m *toolCallCmp) {
107− m.permissionGranted = true
108− }
109−}
110−
111−// NewToolCallCmp creates a new tool call component with the given parent message ID,
112−// tool call, and optional configuration
113−func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp {
114− m := &toolCallCmp{
115− call: tc,
116− parentMessageID: parentMessageID,
117− }
118− for _, opt := range opts {
119− opt(m)
120− }
121− t := styles.CurrentTheme()
122− m.anim = anim.New(anim.Settings{
123− Size: 15,
124− Label: "Working",
125− GradColorA: t.Primary,
126− GradColorB: t.Secondary,
127− LabelColor: t.FgBase,
128− CycleColors: true,
129− })
130− if m.isNested {
131− m.anim = anim.New(anim.Settings{
132− Size: 10,
133− GradColorA: t.Primary,
134− GradColorB: t.Secondary,
135− CycleColors: true,
136− })
137− }
138− return m
139−}
140−
141−// Init initializes the tool call component and starts animations if needed.
142−// Returns a command to start the animation for pending tool calls.
143−func (m *toolCallCmp) Init() tea.Cmd {
144− m.spinning = m.shouldSpin()
145− return m.anim.Init()
146−}
147−
148−// Update handles incoming messages and updates the component state.
149−// Manages animation updates for pending tool calls.
150−func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
151− switch msg := msg.(type) {
152− case anim.StepMsg:
153− var cmds []tea.Cmd
154− for i, nested := range m.nestedToolCalls {
155− if nested.Spinning() {
156− u, cmd := nested.Update(msg)
157− m.nestedToolCalls[i] = u.(ToolCallCmp)
158− cmds = append(cmds, cmd)
159− }
160− }
161− if m.spinning {
162− u, cmd := m.anim.Update(msg)
163− m.anim = u
164− cmds = append(cmds, cmd)
165− }
166− return m, tea.Batch(cmds...)
167− case tea.KeyPressMsg:
168− if key.Matches(msg, CopyKey) {
169− return m, m.copyTool()
170− }
171− }
172− return m, nil
173−}
174−
175−// View renders the tool call component based on its current state.
176−// Shows either a pending animation or the tool-specific rendered result.
177−func (m *toolCallCmp) View() string {
178− box := m.style()
179−
180− if !m.call.Finished && !m.cancelled {
181− return box.Render(m.renderPending())
182− }
183−
184− r := registry.lookup(m.call.Name)
185−
186− if m.isNested {
187− return box.Render(r.Render(m))
188− }
189− return box.Render(r.Render(m))
190−}
191−
192−// State management methods
193−
194−// SetCancelled marks the tool call as cancelled
195−func (m *toolCallCmp) SetCancelled() {
196− m.cancelled = true
197−}
198−
199−func (m *toolCallCmp) copyTool() tea.Cmd {
200− content := m.formatToolForCopy()
201− return tea.Sequence(
202− tea.SetClipboard(content),
203− func() tea.Msg {
204− _ = clipboard.WriteAll(content)
205− return nil
206− },
207− util.ReportInfo("Tool content copied to clipboard"),
208− )
209−}
210−
211−func (m *toolCallCmp) formatToolForCopy() string {
212− var parts []string
213−
214− toolName := prettifyToolName(m.call.Name)
215− parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
216−
217− if m.call.Input != "" {
218− params := m.formatParametersForCopy()
219− if params != "" {
220− parts = append(parts, "### Parameters:")
221− parts = append(parts, params)
222− }
223− }
224−
225− if m.result.ToolCallID != "" {
226− if m.result.IsError {
227− parts = append(parts, "### Error:")
228− parts = append(parts, m.result.Content)
229− } else {
230− parts = append(parts, "### Result:")
231− content := m.formatResultForCopy()
232− if content != "" {
233− parts = append(parts, content)
234− }
235− }
236− } else if m.cancelled {
237− parts = append(parts, "### Status:")
238− parts = append(parts, "Cancelled")
239− } else {
240− parts = append(parts, "### Status:")
241− parts = append(parts, "Pending...")
242− }
243−
244− return strings.Join(parts, "\n\n")
245−}
246−
247−func (m *toolCallCmp) formatParametersForCopy() string {
248− switch m.call.Name {
249− case tools.BashToolName:
250− var params tools.BashParams
251− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
252− cmd := strings.ReplaceAll(params.Command, "\n", " ")
253− cmd = strings.ReplaceAll(cmd, "\t", " ")
254− return fmt.Sprintf("**Command:** %s", cmd)
255− }
256− case tools.ViewToolName:
257− var params tools.ViewParams
258− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
259− var parts []string
260− parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
261− if params.Limit > 0 {
262− parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
263− }
264− if params.Offset > 0 {
265− parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
266− }
267− return strings.Join(parts, "\n")
268− }
269− case tools.EditToolName:
270− var params tools.EditParams
271− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
272− return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
273− }
274− case tools.MultiEditToolName:
275− var params tools.MultiEditParams
276− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
277− var parts []string
278− parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
279− parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
280− return strings.Join(parts, "\n")
281− }
282− case tools.WriteToolName:
283− var params tools.WriteParams
284− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
285− return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
286− }
287− case tools.FetchToolName:
288− var params tools.FetchParams
289− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
290− var parts []string
291− parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
292− if params.Format != "" {
293− parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
294− }
295− if params.Timeout > 0 {
296− parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
297− }
298− return strings.Join(parts, "\n")
299− }
300− case tools.AgenticFetchToolName:
301− var params tools.AgenticFetchParams
302− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
303− var parts []string
304− if params.URL != "" {
305− parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
306− }
307− if params.Prompt != "" {
308− parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
309− }
310− return strings.Join(parts, "\n")
311− }
312− case tools.WebFetchToolName:
313− var params tools.WebFetchParams
314− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
315− return fmt.Sprintf("**URL:** %s", params.URL)
316− }
317− case tools.GrepToolName:
318− var params tools.GrepParams
319− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
320− var parts []string
321− parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
322− if params.Path != "" {
323− parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
324− }
325− if params.Include != "" {
326− parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
327− }
328− if params.LiteralText {
329− parts = append(parts, "**Literal:** true")
330− }
331− return strings.Join(parts, "\n")
332− }
333− case tools.GlobToolName:
334− var params tools.GlobParams
335− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
336− var parts []string
337− parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
338− if params.Path != "" {
339− parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
340− }
341− return strings.Join(parts, "\n")
342− }
343− case tools.LSToolName:
344− var params tools.LSParams
345− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
346− path := params.Path
347− if path == "" {
348− path = "."
349− }
350− return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
351− }
352− case tools.DownloadToolName:
353− var params tools.DownloadParams
354− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
355− var parts []string
356− parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
357− parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
358− if params.Timeout > 0 {
359− parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
360− }
361− return strings.Join(parts, "\n")
362− }
363− case tools.SourcegraphToolName:
364− var params tools.SourcegraphParams
365− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
366− var parts []string
367− parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
368− if params.Count > 0 {
369− parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
370− }
371− if params.ContextWindow > 0 {
372− parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
373− }
374− return strings.Join(parts, "\n")
375− }
376− case tools.DiagnosticsToolName:
377− return "**Project:** diagnostics"
378− case agent.AgentToolName:
379− var params agent.AgentParams
380− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
381− return fmt.Sprintf("**Task:**\n%s", params.Prompt)
382− }
383− }
384−
385− var params map[string]any
386− if json.Unmarshal([]byte(m.call.Input), &params) == nil {
387− var parts []string
388− for key, value := range params {
389− displayKey := strings.ReplaceAll(key, "_", " ")
390− if len(displayKey) > 0 {
391− displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
392− }
393− parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
394− }
395− return strings.Join(parts, "\n")
396− }
397−
398− return ""
399−}
400−
401−func (m *toolCallCmp) formatResultForCopy() string {
402− if m.result.Data != "" {
403− if strings.HasPrefix(m.result.MIMEType, "image/") {
404− return fmt.Sprintf("[Image: %s]", m.result.MIMEType)
405− }
406− return fmt.Sprintf("[Media: %s]", m.result.MIMEType)
407− }
408−
409− switch m.call.Name {
410− case tools.BashToolName:
411− return m.formatBashResultForCopy()
412− case tools.ViewToolName:
413− return m.formatViewResultForCopy()
414− case tools.EditToolName:
415− return m.formatEditResultForCopy()
416− case tools.MultiEditToolName:
417− return m.formatMultiEditResultForCopy()
418− case tools.WriteToolName:
419− return m.formatWriteResultForCopy()
420− case tools.FetchToolName:
421− return m.formatFetchResultForCopy()
422− case tools.AgenticFetchToolName:
423− return m.formatAgenticFetchResultForCopy()
424− case tools.WebFetchToolName:
425− return m.formatWebFetchResultForCopy()
426− case agent.AgentToolName:
427− return m.formatAgentResultForCopy()
428− case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
429− return fmt.Sprintf("```\n%s\n```", m.result.Content)
430− default:
431− return m.result.Content
432− }
433−}
434−
435−func (m *toolCallCmp) formatBashResultForCopy() string {
436− var meta tools.BashResponseMetadata
437− if m.result.Metadata != "" {
438− json.Unmarshal([]byte(m.result.Metadata), &meta)
439− }
440−
441− output := meta.Output
442− if output == "" && m.result.Content != tools.BashNoOutput {
443− output = m.result.Content
444− }
445−
446− if output == "" {
447− return ""
448− }
449−
450− return fmt.Sprintf("```bash\n%s\n```", output)
451−}
452−
453−func (m *toolCallCmp) formatViewResultForCopy() string {
454− var meta tools.ViewResponseMetadata
455− if m.result.Metadata != "" {
456− json.Unmarshal([]byte(m.result.Metadata), &meta)
457− }
458−
459− if meta.Content == "" {
460− return m.result.Content
461− }
462−
463− lang := ""
464− if meta.FilePath != "" {
465− ext := strings.ToLower(filepath.Ext(meta.FilePath))
466− switch ext {
467− case ".go":
468− lang = "go"
469− case ".js", ".mjs":
470− lang = "javascript"
471− case ".ts":
472− lang = "typescript"
473− case ".py":
474− lang = "python"
475− case ".rs":
476− lang = "rust"
477− case ".java":
478− lang = "java"
479− case ".c":
480− lang = "c"
481− case ".cpp", ".cc", ".cxx":
482− lang = "cpp"
483− case ".sh", ".bash":
484− lang = "bash"
485− case ".json":
486− lang = "json"
487− case ".yaml", ".yml":
488− lang = "yaml"
489− case ".xml":
490− lang = "xml"
491− case ".html":
492− lang = "html"
493− case ".css":
494− lang = "css"
495− case ".md":
496− lang = "markdown"
497− }
498− }
499−
500− var result strings.Builder
501− if lang != "" {
502− result.WriteString(fmt.Sprintf("```%s\n", lang))
503− } else {
504− result.WriteString("```\n")
505− }
506− result.WriteString(meta.Content)
507− result.WriteString("\n```")
508−
509− return result.String()
510−}
511−
512−func (m *toolCallCmp) formatEditResultForCopy() string {
513− var meta tools.EditResponseMetadata
514− if m.result.Metadata == "" {
515− return m.result.Content
516− }
517−
518− if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
519− return m.result.Content
520− }
521−
522− var params tools.EditParams
523− json.Unmarshal([]byte(m.call.Input), &params)
524−
525− var result strings.Builder
526−
527− if meta.OldContent != "" || meta.NewContent != "" {
528− fileName := params.FilePath
529− if fileName != "" {
530− fileName = fsext.PrettyPath(fileName)
531− }
532− diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
533−
534− result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
535− result.WriteString("```diff\n")
536− result.WriteString(diffContent)
537− result.WriteString("\n```")
538− }
539−
540− return result.String()
541−}
542−
543−func (m *toolCallCmp) formatMultiEditResultForCopy() string {
544− var meta tools.MultiEditResponseMetadata
545− if m.result.Metadata == "" {
546− return m.result.Content
547− }
548−
549− if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
550− return m.result.Content
551− }
552−
553− var params tools.MultiEditParams
554− json.Unmarshal([]byte(m.call.Input), &params)
555−
556− var result strings.Builder
557− if meta.OldContent != "" || meta.NewContent != "" {
558− fileName := params.FilePath
559− if fileName != "" {
560− fileName = fsext.PrettyPath(fileName)
561− }
562− diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
563−
564− result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
565− result.WriteString("```diff\n")
566− result.WriteString(diffContent)
567− result.WriteString("\n```")
568− }
569−
570− return result.String()
571−}
572−
573−func (m *toolCallCmp) formatWriteResultForCopy() string {
574− var params tools.WriteParams
575− if json.Unmarshal([]byte(m.call.Input), &params) != nil {
576− return m.result.Content
577− }
578−
579− lang := ""
580− if params.FilePath != "" {
581− ext := strings.ToLower(filepath.Ext(params.FilePath))
582− switch ext {
583− case ".go":
584− lang = "go"
585− case ".js", ".mjs":
586− lang = "javascript"
587− case ".ts":
588− lang = "typescript"
589− case ".py":
590− lang = "python"
591− case ".rs":
592− lang = "rust"
593− case ".java":
594− lang = "java"
595− case ".c":
596− lang = "c"
597− case ".cpp", ".cc", ".cxx":
598− lang = "cpp"
599− case ".sh", ".bash":
600− lang = "bash"
601− case ".json":
602− lang = "json"
603− case ".yaml", ".yml":
604− lang = "yaml"
605− case ".xml":
606− lang = "xml"
607− case ".html":
608− lang = "html"
609− case ".css":
610− lang = "css"
611− case ".md":
612− lang = "markdown"
613− }
614− }
615−
616− var result strings.Builder
617− result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
618− if lang != "" {
619− result.WriteString(fmt.Sprintf("```%s\n", lang))
620− } else {
621− result.WriteString("```\n")
622− }
623− result.WriteString(params.Content)
624− result.WriteString("\n```")
625−
626− return result.String()
627−}
628−
629−func (m *toolCallCmp) formatFetchResultForCopy() string {
630− var params tools.FetchParams
631− if json.Unmarshal([]byte(m.call.Input), &params) != nil {
632− return m.result.Content
633− }
634−
635− var result strings.Builder
636− if params.URL != "" {
637− result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
638− }
639− if params.Format != "" {
640− result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
641− }
642− if params.Timeout > 0 {
643− result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
644− }
645− result.WriteString("\n")
646−
647− result.WriteString(m.result.Content)
648−
649− return result.String()
650−}
651−
652−func (m *toolCallCmp) formatAgenticFetchResultForCopy() string {
653− var params tools.AgenticFetchParams
654− if json.Unmarshal([]byte(m.call.Input), &params) != nil {
655− return m.result.Content
656− }
657−
658− var result strings.Builder
659− if params.URL != "" {
660− result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
661− }
662− if params.Prompt != "" {
663− result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
664− }
665−
666− result.WriteString("```markdown\n")
667− result.WriteString(m.result.Content)
668− result.WriteString("\n```")
669−
670− return result.String()
671−}
672−
673−func (m *toolCallCmp) formatWebFetchResultForCopy() string {
674− var params tools.WebFetchParams
675− if json.Unmarshal([]byte(m.call.Input), &params) != nil {
676− return m.result.Content
677− }
678−
679− var result strings.Builder
680− result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
681− result.WriteString("```markdown\n")
682− result.WriteString(m.result.Content)
683− result.WriteString("\n```")
684−
685− return result.String()
686−}
687−
688−func (m *toolCallCmp) formatAgentResultForCopy() string {
689− var result strings.Builder
690−
691− if len(m.nestedToolCalls) > 0 {
692− result.WriteString("### Nested Tool Calls:\n")
693− for i, nestedCall := range m.nestedToolCalls {
694− nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy()
695− indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n ")
696− result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent))
697− if i < len(m.nestedToolCalls)-1 {
698− result.WriteString("\n")
699− }
700− }
701−
702− if m.result.Content != "" {
703− result.WriteString("\n### Final Result:\n")
704− }
705− }
706−
707− if m.result.Content != "" {
708− result.WriteString(fmt.Sprintf("```markdown\n%s\n```", m.result.Content))
709− }
710−
711− return result.String()
712−}
713−
714−// SetToolCall updates the tool call data and stops spinning if finished
715−func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
716− m.call = call
717− if m.call.Finished {
718− m.spinning = false
719− }
720−}
721−
722−// ParentMessageID returns the ID of the message that initiated this tool call
723−func (m *toolCallCmp) ParentMessageID() string {
724− return m.parentMessageID
725−}
726−
727−// SetToolResult updates the tool result and stops the spinning animation
728−func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
729− m.result = result
730− m.spinning = false
731−}
732−
733−// GetToolCall returns the current tool call data
734−func (m *toolCallCmp) GetToolCall() message.ToolCall {
735− return m.call
736−}
737−
738−// GetToolResult returns the current tool result data
739−func (m *toolCallCmp) GetToolResult() message.ToolResult {
740− return m.result
741−}
742−
743−// GetNestedToolCalls returns the nested tool calls
744−func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
745− return m.nestedToolCalls
746−}
747−
748−// SetNestedToolCalls sets the nested tool calls
749−func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
750− m.nestedToolCalls = calls
751− for _, nested := range m.nestedToolCalls {
752− nested.SetSize(m.width, 0)
753− }
754−}
755−
756−// SetIsNested sets whether this tool call is nested within another
757−func (m *toolCallCmp) SetIsNested(isNested bool) {
758− m.isNested = isNested
759−}
760−
761−// Rendering methods
762−
763−// renderPending displays the tool name with a loading animation for pending tool calls
764−func (m *toolCallCmp) renderPending() string {
765− t := styles.CurrentTheme()
766− icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
767− if m.isNested {
768− tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
769− return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
770− }
771− tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
772− return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
773−}
774−
775−// style returns the lipgloss style for the tool call component.
776−// Applies muted colors and focus-dependent border styles.
777−func (m *toolCallCmp) style() lipgloss.Style {
778− t := styles.CurrentTheme()
779−
780− if m.isNested {
781− return t.S().Muted
782− }
783− style := t.S().Muted.PaddingLeft(2)
784−
785− if m.focused {
786− style = style.PaddingLeft(1).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
787− }
788− return style
789−}
790−
791−// textWidth calculates the available width for text content,
792−// accounting for borders and padding
793−func (m *toolCallCmp) textWidth() int {
794− if m.isNested {
795− return m.width - 6
796− }
797− return m.width - 5 // take into account the border and PaddingLeft
798−}
799−
800−// fit truncates content to fit within the specified width with ellipsis
801−func (m *toolCallCmp) fit(content string, width int) string {
802− if lipgloss.Width(content) <= width {
803− return content
804− }
805− t := styles.CurrentTheme()
806− lineStyle := t.S().Muted
807− dots := lineStyle.Render("…")
808− return ansi.Truncate(content, width, dots)
809−}
810−
811−// Focus management methods
812−
813−// Blur removes focus from the tool call component
814−func (m *toolCallCmp) Blur() tea.Cmd {
815− m.focused = false
816− return nil
817−}
818−
819−// Focus sets focus on the tool call component
820−func (m *toolCallCmp) Focus() tea.Cmd {
821− m.focused = true
822− return nil
823−}
824−
825−// IsFocused returns whether the tool call component is currently focused
826−func (m *toolCallCmp) IsFocused() bool {
827− return m.focused
828−}
829−
830−// Size management methods
831−
832−// GetSize returns the current dimensions of the tool call component
833−func (m *toolCallCmp) GetSize() (int, int) {
834− return m.width, 0
835−}
836−
837−// SetSize updates the width of the tool call component for text wrapping
838−func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
839− m.width = width
840− for _, nested := range m.nestedToolCalls {
841− nested.SetSize(width, height)
842− }
843− return nil
844−}
845−
846−// shouldSpin determines whether the tool call should show a loading animation.
847−// Returns true if the tool call is not finished or if the result doesn't match the call ID.
848−func (m *toolCallCmp) shouldSpin() bool {
849− return !m.call.Finished && !m.cancelled
850−}
851−
852−// Spinning returns whether the tool call is currently showing a loading animation
853−func (m *toolCallCmp) Spinning() bool {
854− if m.spinning {
855− return true
856− }
857− for _, nested := range m.nestedToolCalls {
858− if nested.Spinning() {
859− return true
860− }
861− }
862− return m.spinning
863−}
864−
865−func (m *toolCallCmp) ID() string {
866− return m.call.ID
867−}
868−
869−// SetPermissionRequested marks that a permission request was made for this tool call
870−func (m *toolCallCmp) SetPermissionRequested() {
871− m.permissionRequested = true
872−}
873−
874−// SetPermissionGranted marks that permission was granted for this tool call
875−func (m *toolCallCmp) SetPermissionGranted() {
876− m.permissionGranted = true
877−}
internal/tui/components/chat/sidebar/sidebar.godeleted−608
@@ -1,608 +0,0 @@
1−package sidebar
2−
3−import (
4− "context"
5− "fmt"
6− "slices"
7− "strings"
8−
9− tea "charm.land/bubbletea/v2"
10− "charm.land/lipgloss/v2"
11− "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/csync"
13− "github.com/charmbracelet/crush/internal/diff"
14− "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/history"
16− "github.com/charmbracelet/crush/internal/home"
17− "github.com/charmbracelet/crush/internal/lsp"
18− "github.com/charmbracelet/crush/internal/pubsub"
19− "github.com/charmbracelet/crush/internal/session"
20− "github.com/charmbracelet/crush/internal/tui/components/chat"
21− "github.com/charmbracelet/crush/internal/tui/components/core"
22− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
23− "github.com/charmbracelet/crush/internal/tui/components/files"
24− "github.com/charmbracelet/crush/internal/tui/components/logo"
25− lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
26− "github.com/charmbracelet/crush/internal/tui/components/mcp"
27− "github.com/charmbracelet/crush/internal/tui/styles"
28− "github.com/charmbracelet/crush/internal/tui/util"
29− "github.com/charmbracelet/crush/internal/version"
30− "golang.org/x/text/cases"
31− "golang.org/x/text/language"
32−)
33−
34−type FileHistory struct {
35− initialVersion history.File
36− latestVersion history.File
37−}
38−
39−const LogoHeightBreakpoint = 30
40−
41−// Default maximum number of items to show in each section
42−const (
43− DefaultMaxFilesShown = 10
44− DefaultMaxLSPsShown = 8
45− DefaultMaxMCPsShown = 8
46− MinItemsPerSection = 2 // Minimum items to show per section
47−)
48−
49−type SessionFile struct {
50− History FileHistory
51− FilePath string
52− Additions int
53− Deletions int
54−}
55−type SessionFilesMsg struct {
56− Files []SessionFile
57−}
58−
59−type Sidebar interface {
60− util.Model
61− layout.Sizeable
62− SetSession(session session.Session) tea.Cmd
63− SetCompactMode(bool)
64−}
65−
66−type sidebarCmp struct {
67− width, height int
68− session session.Session
69− logo string
70− cwd string
71− lspClients *csync.Map[string, *lsp.Client]
72− compactMode bool
73− history history.Service
74− files *csync.Map[string, SessionFile]
75−}
76−
77−func New(history history.Service, lspClients *csync.Map[string, *lsp.Client], compact bool) Sidebar {
78− return &sidebarCmp{
79− lspClients: lspClients,
80− history: history,
81− compactMode: compact,
82− files: csync.NewMap[string, SessionFile](),
83− }
84−}
85−
86−func (m *sidebarCmp) Init() tea.Cmd {
87− return nil
88−}
89−
90−func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
91− switch msg := msg.(type) {
92− case SessionFilesMsg:
93− m.files = csync.NewMap[string, SessionFile]()
94− for _, file := range msg.Files {
95− m.files.Set(file.FilePath, file)
96− }
97− return m, nil
98−
99− case chat.SessionClearedMsg:
100− m.session = session.Session{}
101− case pubsub.Event[history.File]:
102− return m, m.handleFileHistoryEvent(msg)
103− case pubsub.Event[session.Session]:
104− if msg.Type == pubsub.UpdatedEvent {
105− if m.session.ID == msg.Payload.ID {
106− m.session = msg.Payload
107− }
108− }
109− }
110− return m, nil
111−}
112−
113−func (m *sidebarCmp) View() string {
114− t := styles.CurrentTheme()
115− parts := []string{}
116−
117− style := t.S().Base.
118− Width(m.width).
119− Height(m.height).
120− Padding(1)
121− if m.compactMode {
122− style = style.PaddingTop(0)
123− }
124−
125− if !m.compactMode {
126− if m.height > LogoHeightBreakpoint {
127− parts = append(parts, m.logo)
128− } else {
129− // Use a smaller logo for smaller screens
130− parts = append(parts,
131− logo.SmallRender(m.width-style.GetHorizontalFrameSize()),
132− "")
133− }
134− }
135−
136− if !m.compactMode && m.session.ID != "" {
137− parts = append(parts, t.S().Muted.Render(m.session.Title), "")
138− } else if m.session.ID != "" {
139− parts = append(parts, t.S().Text.Render(m.session.Title), "")
140− }
141−
142− if !m.compactMode {
143− parts = append(parts,
144− m.cwd,
145− "",
146− )
147− }
148− parts = append(parts,
149− m.currentModelBlock(),
150− )
151−
152− // Check if we should use horizontal layout for sections
153− if m.compactMode && m.width > m.height {
154− // Horizontal layout for compact mode when width > height
155− sectionsContent := m.renderSectionsHorizontal()
156− if sectionsContent != "" {
157− parts = append(parts, "", sectionsContent)
158− }
159− } else {
160− // Vertical layout (default)
161− if m.session.ID != "" {
162− parts = append(parts, "", m.filesBlock())
163− }
164− parts = append(parts,
165− "",
166− m.lspBlock(),
167− "",
168− m.mcpBlock(),
169− )
170− }
171−
172− return style.Render(
173− lipgloss.JoinVertical(lipgloss.Left, parts...),
174− )
175−}
176−
177−func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
178− return func() tea.Msg {
179− file := event.Payload
180− found := false
181− for existing := range m.files.Seq() {
182− if existing.FilePath != file.Path {
183− continue
184− }
185− if existing.History.latestVersion.Version < file.Version {
186− existing.History.latestVersion = file
187− } else if file.Version == 0 {
188− existing.History.initialVersion = file
189− } else {
190− // If the version is not greater than the latest, we ignore it
191− continue
192− }
193− before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content)
194− after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content)
195− path := existing.History.initialVersion.Path
196− cwd := config.Get().WorkingDir()
197− path = strings.TrimPrefix(path, cwd)
198− _, additions, deletions := diff.GenerateDiff(before, after, path)
199− existing.Additions = additions
200− existing.Deletions = deletions
201− m.files.Set(file.Path, existing)
202− found = true
203− break
204− }
205− if found {
206− return nil
207− }
208− sf := SessionFile{
209− History: FileHistory{
210− initialVersion: file,
211− latestVersion: file,
212− },
213− FilePath: file.Path,
214− Additions: 0,
215− Deletions: 0,
216− }
217− m.files.Set(file.Path, sf)
218− return nil
219− }
220−}
221−
222−func (m *sidebarCmp) loadSessionFiles() tea.Msg {
223− files, err := m.history.ListBySession(context.Background(), m.session.ID)
224− if err != nil {
225− return util.InfoMsg{
226− Type: util.InfoTypeError,
227− Msg: err.Error(),
228− }
229− }
230−
231− fileMap := make(map[string]FileHistory)
232−
233− for _, file := range files {
234− if existing, ok := fileMap[file.Path]; ok {
235− // Update the latest version
236− existing.latestVersion = file
237− fileMap[file.Path] = existing
238− } else {
239− // Add the initial version
240− fileMap[file.Path] = FileHistory{
241− initialVersion: file,
242− latestVersion: file,
243− }
244− }
245− }
246−
247− sessionFiles := make([]SessionFile, 0, len(fileMap))
248− for path, fh := range fileMap {
249− cwd := config.Get().WorkingDir()
250− path = strings.TrimPrefix(path, cwd)
251− before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content)
252− after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content)
253− _, additions, deletions := diff.GenerateDiff(before, after, path)
254− sessionFiles = append(sessionFiles, SessionFile{
255− History: fh,
256− FilePath: path,
257− Additions: additions,
258− Deletions: deletions,
259− })
260− }
261−
262− return SessionFilesMsg{
263− Files: sessionFiles,
264− }
265−}
266−
267−func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
268− m.logo = m.logoBlock()
269− m.cwd = cwd()
270− m.width = width
271− m.height = height
272− return nil
273−}
274−
275−func (m *sidebarCmp) GetSize() (int, int) {
276− return m.width, m.height
277−}
278−
279−func (m *sidebarCmp) logoBlock() string {
280− t := styles.CurrentTheme()
281− return logo.Render(version.Version, true, logo.Opts{
282− FieldColor: t.Primary,
283− TitleColorA: t.Secondary,
284− TitleColorB: t.Primary,
285− CharmColor: t.Secondary,
286− VersionColor: t.Primary,
287− Width: m.width - 2,
288− })
289−}
290−
291−func (m *sidebarCmp) getMaxWidth() int {
292− return min(m.width-2, 58) // -2 for padding
293−}
294−
295−// calculateAvailableHeight estimates how much height is available for dynamic content
296−func (m *sidebarCmp) calculateAvailableHeight() int {
297− usedHeight := 0
298−
299− if !m.compactMode {
300− if m.height > LogoHeightBreakpoint {
301− usedHeight += 7 // Approximate logo height
302− } else {
303− usedHeight += 2 // Smaller logo height
304− }
305− usedHeight += 1 // Empty line after logo
306− }
307−
308− if m.session.ID != "" {
309− usedHeight += 1 // Title line
310− usedHeight += 1 // Empty line after title
311− }
312−
313− if !m.compactMode {
314− usedHeight += 1 // CWD line
315− usedHeight += 1 // Empty line after CWD
316− }
317−
318− usedHeight += 2 // Model info
319−
320− usedHeight += 6 // 3 sections × 2 lines each (header + empty line)
321−
322− // Base padding
323− usedHeight += 2 // Top and bottom padding
324−
325− return max(0, m.height-usedHeight)
326−}
327−
328−// getDynamicLimits calculates how many items to show in each section based on available height
329−func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
330− availableHeight := m.calculateAvailableHeight()
331−
332− // If we have very little space, use minimum values
333− if availableHeight < 10 {
334− return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
335− }
336−
337− // Distribute available height among the three sections
338− // Give priority to files, then LSPs, then MCPs
339− totalSections := 3
340− heightPerSection := availableHeight / totalSections
341−
342− // Calculate limits for each section, ensuring minimums
343− maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
344− maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
345− maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
346−
347− // If we have extra space, give it to files first
348− remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
349− if remainingHeight > 0 {
350− extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
351− maxFiles += extraForFiles
352− remainingHeight -= extraForFiles
353−
354− if remainingHeight > 0 {
355− extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
356− maxLSPs += extraForLSPs
357− remainingHeight -= extraForLSPs
358−
359− if remainingHeight > 0 {
360− maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
361− }
362− }
363− }
364−
365− return maxFiles, maxLSPs, maxMCPs
366−}
367−
368−// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
369−func (m *sidebarCmp) renderSectionsHorizontal() string {
370− // Calculate available width for each section
371− totalWidth := m.width - 4 // Account for padding and spacing
372− sectionWidth := min(50, totalWidth/3)
373−
374− // Get the sections content with limited height
375− var filesContent, lspContent, mcpContent string
376−
377− filesContent = m.filesBlockCompact(sectionWidth)
378− lspContent = m.lspBlockCompact(sectionWidth)
379− mcpContent = m.mcpBlockCompact(sectionWidth)
380−
381− return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
382−}
383−
384−// filesBlockCompact renders the files block with limited width and height for horizontal layout
385−func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
386− // Convert map to slice and handle type conversion
387− sessionFiles := slices.Collect(m.files.Seq())
388− fileSlice := make([]files.SessionFile, len(sessionFiles))
389− for i, sf := range sessionFiles {
390− fileSlice[i] = files.SessionFile{
391− History: files.FileHistory{
392− InitialVersion: sf.History.initialVersion,
393− LatestVersion: sf.History.latestVersion,
394− },
395− FilePath: sf.FilePath,
396− Additions: sf.Additions,
397− Deletions: sf.Deletions,
398− }
399− }
400−
401− // Limit items for horizontal layout
402− maxItems := min(5, len(fileSlice))
403− availableHeight := m.height - 8 // Reserve space for header and other content
404− if availableHeight > 0 {
405− maxItems = min(maxItems, availableHeight)
406− }
407−
408− return files.RenderFileBlock(fileSlice, files.RenderOptions{
409− MaxWidth: maxWidth,
410− MaxItems: maxItems,
411− ShowSection: true,
412− SectionName: "Modified Files",
413− }, true)
414−}
415−
416−// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
417−func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
418− // Limit items for horizontal layout
419− lspConfigs := config.Get().LSP.Sorted()
420− maxItems := min(5, len(lspConfigs))
421− availableHeight := m.height - 8
422− if availableHeight > 0 {
423− maxItems = min(maxItems, availableHeight)
424− }
425−
426− return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
427− MaxWidth: maxWidth,
428− MaxItems: maxItems,
429− ShowSection: true,
430− SectionName: "LSPs",
431− }, true)
432−}
433−
434−// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
435−func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
436− // Limit items for horizontal layout
437− maxItems := min(5, len(config.Get().MCP.Sorted()))
438− availableHeight := m.height - 8
439− if availableHeight > 0 {
440− maxItems = min(maxItems, availableHeight)
441− }
442−
443− return mcp.RenderMCPBlock(mcp.RenderOptions{
444− MaxWidth: maxWidth,
445− MaxItems: maxItems,
446− ShowSection: true,
447− SectionName: "MCPs",
448− }, true)
449−}
450−
451−func (m *sidebarCmp) filesBlock() string {
452− // Convert map to slice and handle type conversion
453− sessionFiles := slices.Collect(m.files.Seq())
454− fileSlice := make([]files.SessionFile, len(sessionFiles))
455− for i, sf := range sessionFiles {
456− fileSlice[i] = files.SessionFile{
457− History: files.FileHistory{
458− InitialVersion: sf.History.initialVersion,
459− LatestVersion: sf.History.latestVersion,
460− },
461− FilePath: sf.FilePath,
462− Additions: sf.Additions,
463− Deletions: sf.Deletions,
464− }
465− }
466−
467− // Limit the number of files shown
468− maxFiles, _, _ := m.getDynamicLimits()
469− maxFiles = min(len(fileSlice), maxFiles)
470−
471− return files.RenderFileBlock(fileSlice, files.RenderOptions{
472− MaxWidth: m.getMaxWidth(),
473− MaxItems: maxFiles,
474− ShowSection: true,
475− SectionName: core.Section("Modified Files", m.getMaxWidth()),
476− }, true)
477−}
478−
479−func (m *sidebarCmp) lspBlock() string {
480− // Limit the number of LSPs shown
481− _, maxLSPs, _ := m.getDynamicLimits()
482−
483− return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
484− MaxWidth: m.getMaxWidth(),
485− MaxItems: maxLSPs,
486− ShowSection: true,
487− SectionName: core.Section("LSPs", m.getMaxWidth()),
488− }, true)
489−}
490−
491−func (m *sidebarCmp) mcpBlock() string {
492− // Limit the number of MCPs shown
493− _, _, maxMCPs := m.getDynamicLimits()
494− mcps := config.Get().MCP.Sorted()
495− maxMCPs = min(len(mcps), maxMCPs)
496−
497− return mcp.RenderMCPBlock(mcp.RenderOptions{
498− MaxWidth: m.getMaxWidth(),
499− MaxItems: maxMCPs,
500− ShowSection: true,
501− SectionName: core.Section("MCPs", m.getMaxWidth()),
502− }, true)
503−}
504−
505−func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
506− t := styles.CurrentTheme()
507− // Format tokens in human-readable format (e.g., 110K, 1.2M)
508− var formattedTokens string
509− switch {
510− case tokens >= 1_000_000:
511− formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
512− case tokens >= 1_000:
513− formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
514− default:
515− formattedTokens = fmt.Sprintf("%d", tokens)
516− }
517−
518− // Remove .0 suffix if present
519− if strings.HasSuffix(formattedTokens, ".0K") {
520− formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
521− }
522− if strings.HasSuffix(formattedTokens, ".0M") {
523− formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
524− }
525−
526− percentage := (float64(tokens) / float64(contextWindow)) * 100
527−
528− baseStyle := t.S().Base
529−
530− formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
531−
532− formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
533− formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
534− formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
535− if percentage > 80 {
536− // add the warning icon
537− formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
538− }
539−
540− return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
541−}
542−
543−func (s *sidebarCmp) currentModelBlock() string {
544− cfg := config.Get()
545− agentCfg := cfg.Agents[config.AgentCoder]
546−
547− selectedModel := cfg.Models[agentCfg.Model]
548−
549− model := config.Get().GetModelByType(agentCfg.Model)
550−
551− t := styles.CurrentTheme()
552−
553− modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
554− modelName := t.S().Text.Render(model.Name)
555− modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
556− parts := []string{
557− modelInfo,
558− }
559− if model.CanReason {
560− reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
561− if len(model.ReasoningLevels) == 0 {
562− formatter := cases.Title(language.English, cases.NoLower)
563− if selectedModel.Think {
564− parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
565− } else {
566− parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
567− }
568− } else {
569− reasoningEffort := model.DefaultReasoningEffort
570− if selectedModel.ReasoningEffort != "" {
571− reasoningEffort = selectedModel.ReasoningEffort
572− }
573− formatter := cases.Title(language.English, cases.NoLower)
574− parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
575− }
576− }
577− if s.session.ID != "" {
578− parts = append(
579− parts,
580− " "+formatTokensAndCost(
581− s.session.CompletionTokens+s.session.PromptTokens,
582− model.ContextWindow,
583− s.session.Cost,
584− ),
585− )
586− }
587− return lipgloss.JoinVertical(
588− lipgloss.Left,
589− parts...,
590− )
591−}
592−
593−// SetSession implements Sidebar.
594−func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
595− m.session = session
596− return m.loadSessionFiles
597−}
598−
599−// SetCompactMode sets the compact mode for the sidebar.
600−func (m *sidebarCmp) SetCompactMode(compact bool) {
601− m.compactMode = compact
602−}
603−
604−func cwd() string {
605− cwd := config.Get().WorkingDir()
606− t := styles.CurrentTheme()
607− return t.S().Muted.Render(home.Short(cwd))
608−}
internal/tui/components/chat/splash/keys.godeleted−58
@@ -1,58 +0,0 @@
1−package splash
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Select,
9− Next,
10− Previous,
11− Yes,
12− No,
13− Tab,
14− LeftRight,
15− Back,
16− Copy key.Binding
17−}
18−
19−func DefaultKeyMap() KeyMap {
20− return KeyMap{
21− Select: key.NewBinding(
22− key.WithKeys("enter", "ctrl+y"),
23− key.WithHelp("enter", "confirm"),
24− ),
25− Next: key.NewBinding(
26− key.WithKeys("down", "ctrl+n"),
27− key.WithHelp("↓", "next item"),
28− ),
29− Previous: key.NewBinding(
30− key.WithKeys("up", "ctrl+p"),
31− key.WithHelp("↑", "previous item"),
32− ),
33− Yes: key.NewBinding(
34− key.WithKeys("y", "Y"),
35− key.WithHelp("y", "yes"),
36− ),
37− No: key.NewBinding(
38− key.WithKeys("n", "N"),
39− key.WithHelp("n", "no"),
40− ),
41− Tab: key.NewBinding(
42− key.WithKeys("tab"),
43− key.WithHelp("tab", "switch"),
44− ),
45− LeftRight: key.NewBinding(
46− key.WithKeys("left", "right"),
47− key.WithHelp("←/→", "switch"),
48− ),
49− Back: key.NewBinding(
50− key.WithKeys("esc", "alt+esc"),
51− key.WithHelp("esc", "back"),
52− ),
53− Copy: key.NewBinding(
54− key.WithKeys("c"),
55− key.WithHelp("c", "copy url"),
56− ),
57− }
58−}
internal/tui/components/chat/splash/splash.godeleted−874
@@ -1,874 +0,0 @@
1−package splash
2−
3−import (
4− "fmt"
5− "strings"
6− "time"
7−
8− "charm.land/bubbles/v2/key"
9− "charm.land/bubbles/v2/spinner"
10− tea "charm.land/bubbletea/v2"
11− "charm.land/catwalk/pkg/catwalk"
12− "charm.land/lipgloss/v2"
13− "github.com/charmbracelet/crush/internal/agent"
14− hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
15− "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/home"
17− "github.com/charmbracelet/crush/internal/tui/components/chat"
18− "github.com/charmbracelet/crush/internal/tui/components/core"
19− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20− "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
21− "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
22− "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
23− "github.com/charmbracelet/crush/internal/tui/components/logo"
24− lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
25− "github.com/charmbracelet/crush/internal/tui/components/mcp"
26− "github.com/charmbracelet/crush/internal/tui/exp/list"
27− "github.com/charmbracelet/crush/internal/tui/styles"
28− "github.com/charmbracelet/crush/internal/tui/util"
29− "github.com/charmbracelet/crush/internal/version"
30−)
31−
32−type Splash interface {
33− util.Model
34− layout.Sizeable
35− layout.Help
36− Cursor() *tea.Cursor
37− // SetOnboarding controls whether the splash shows model selection UI
38− SetOnboarding(bool)
39− // SetProjectInit controls whether the splash shows project initialization prompt
40− SetProjectInit(bool)
41−
42− // Showing API key input
43− IsShowingAPIKey() bool
44−
45− // IsAPIKeyValid returns whether the API key is valid
46− IsAPIKeyValid() bool
47−
48− // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
49− IsShowingHyperOAuth2() bool
50−
51− // IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow
52− IsShowingCopilotOAuth2() bool
53−}
54−
55−const (
56− SplashScreenPaddingY = 1 // Padding Y for the splash screen
57−
58− LogoGap = 6
59−)
60−
61−// OnboardingCompleteMsg is sent when onboarding is complete
62−type (
63− OnboardingCompleteMsg struct{}
64− SubmitAPIKeyMsg struct{}
65−)
66−
67−type splashCmp struct {
68− width, height int
69− keyMap KeyMap
70− logoRendered string
71−
72− // State
73− isOnboarding bool
74− needsProjectInit bool
75− needsAPIKey bool
76− selectedNo bool
77−
78− listHeight int
79− modelList *models.ModelListComponent
80− apiKeyInput *models.APIKeyInput
81− selectedModel *models.ModelOption
82− isAPIKeyValid bool
83− apiKeyValue string
84−
85− // Hyper device flow state
86− hyperDeviceFlow *hyper.DeviceFlow
87− showHyperDeviceFlow bool
88−
89− // Copilot device flow state
90− copilotDeviceFlow *copilot.DeviceFlow
91− showCopilotDeviceFlow bool
92−}
93−
94−func New() Splash {
95− keyMap := DefaultKeyMap()
96− listKeyMap := list.DefaultKeyMap()
97− listKeyMap.Down.SetEnabled(false)
98− listKeyMap.Up.SetEnabled(false)
99− listKeyMap.HalfPageDown.SetEnabled(false)
100− listKeyMap.HalfPageUp.SetEnabled(false)
101− listKeyMap.Home.SetEnabled(false)
102− listKeyMap.End.SetEnabled(false)
103− listKeyMap.DownOneItem = keyMap.Next
104− listKeyMap.UpOneItem = keyMap.Previous
105−
106− modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
107− apiKeyInput := models.NewAPIKeyInput()
108−
109− return &splashCmp{
110− width: 0,
111− height: 0,
112− keyMap: keyMap,
113− logoRendered: "",
114− modelList: modelList,
115− apiKeyInput: apiKeyInput,
116− selectedNo: false,
117− }
118−}
119−
120−func (s *splashCmp) SetOnboarding(onboarding bool) {
121− s.isOnboarding = onboarding
122−}
123−
124−func (s *splashCmp) SetProjectInit(needsInit bool) {
125− s.needsProjectInit = needsInit
126−}
127−
128−// GetSize implements SplashPage.
129−func (s *splashCmp) GetSize() (int, int) {
130− return s.width, s.height
131−}
132−
133−// Init implements SplashPage.
134−func (s *splashCmp) Init() tea.Cmd {
135− return tea.Batch(
136− s.modelList.Init(),
137− s.apiKeyInput.Init(),
138− )
139−}
140−
141−// SetSize implements SplashPage.
142−func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
143− wasSmallScreen := s.isSmallScreen()
144− rerenderLogo := width != s.width
145− s.height = height
146− s.width = width
147− if rerenderLogo || wasSmallScreen != s.isSmallScreen() {
148− s.logoRendered = s.logoBlock()
149− }
150− // remove padding, logo height, gap, title space
151− s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
152− listWidth := min(60, width)
153− s.apiKeyInput.SetWidth(width - 2)
154− return s.modelList.SetSize(listWidth, s.listHeight)
155−}
156−
157−// Update implements SplashPage.
158−func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
159− switch msg := msg.(type) {
160− case tea.WindowSizeMsg:
161− return s, s.SetSize(msg.Width, msg.Height)
162− case hyper.DeviceFlowCompletedMsg:
163− s.showHyperDeviceFlow = false
164− return s, s.saveAPIKeyAndContinue(msg.Token, true)
165− case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
166− if s.hyperDeviceFlow != nil {
167− u, cmd := s.hyperDeviceFlow.Update(msg)
168− s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
169− return s, cmd
170− }
171− return s, nil
172− case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
173− if s.copilotDeviceFlow != nil {
174− u, cmd := s.copilotDeviceFlow.Update(msg)
175− s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
176− return s, cmd
177− }
178− return s, nil
179− case copilot.DeviceFlowCompletedMsg:
180− s.showCopilotDeviceFlow = false
181− return s, s.saveAPIKeyAndContinue(msg.Token, true)
182− case models.APIKeyStateChangeMsg:
183− u, cmd := s.apiKeyInput.Update(msg)
184− s.apiKeyInput = u.(*models.APIKeyInput)
185− if msg.State == models.APIKeyInputStateVerified {
186− return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
187− return SubmitAPIKeyMsg{}
188− })
189− }
190− return s, cmd
191− case SubmitAPIKeyMsg:
192− if s.isAPIKeyValid {
193− return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
194− }
195− case tea.KeyPressMsg:
196− switch {
197− case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow:
198− return s, s.hyperDeviceFlow.CopyCode()
199− case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
200− return s, s.copilotDeviceFlow.CopyCode()
201− case key.Matches(msg, s.keyMap.Back):
202− switch {
203− case s.showHyperDeviceFlow:
204− s.hyperDeviceFlow = nil
205− s.showHyperDeviceFlow = false
206− return s, nil
207− case s.showCopilotDeviceFlow:
208− s.copilotDeviceFlow = nil
209− s.showCopilotDeviceFlow = false
210− return s, nil
211− case s.isAPIKeyValid:
212− return s, nil
213− case s.needsAPIKey:
214− s.needsAPIKey = false
215− s.selectedModel = nil
216− s.isAPIKeyValid = false
217− s.apiKeyValue = ""
218− s.apiKeyInput.Reset()
219− return s, nil
220− }
221− case key.Matches(msg, s.keyMap.Select):
222− switch {
223− case s.showHyperDeviceFlow:
224− return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
225− case s.showCopilotDeviceFlow:
226− return s, s.copilotDeviceFlow.CopyCodeAndOpenURL()
227− case s.isAPIKeyValid:
228− return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
229− case s.isOnboarding && !s.needsAPIKey:
230− selectedItem := s.modelList.SelectedModel()
231− if selectedItem == nil {
232− return s, nil
233− }
234− if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
235− cmd := s.setPreferredModel(*selectedItem)
236− s.isOnboarding = false
237− return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
238− } else {
239− switch selectedItem.Provider.ID {
240− case hyperp.Name:
241− s.selectedModel = selectedItem
242− s.showHyperDeviceFlow = true
243− s.hyperDeviceFlow = hyper.NewDeviceFlow()
244− s.hyperDeviceFlow.SetWidth(min(s.width-2, 60))
245− return s, s.hyperDeviceFlow.Init()
246− case catwalk.InferenceProviderCopilot:
247− if token, ok := config.Get().ImportCopilot(); ok {
248− s.selectedModel = selectedItem
249− return s, s.saveAPIKeyAndContinue(token, true)
250− }
251− s.selectedModel = selectedItem
252− s.showCopilotDeviceFlow = true
253− s.copilotDeviceFlow = copilot.NewDeviceFlow()
254− s.copilotDeviceFlow.SetWidth(min(s.width-2, 60))
255− return s, s.copilotDeviceFlow.Init()
256− }
257− // Provider not configured, show API key input
258− s.needsAPIKey = true
259− s.selectedModel = selectedItem
260− s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
261− return s, nil
262− }
263− case s.needsAPIKey:
264− // Handle API key submission
265− s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
266− if s.apiKeyValue == "" {
267− return s, nil
268− }
269−
270− provider, err := s.getProvider(s.selectedModel.Provider.ID)
271− if err != nil || provider == nil {
272− return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID))
273− }
274− providerConfig := config.ProviderConfig{
275− ID: string(s.selectedModel.Provider.ID),
276− Name: s.selectedModel.Provider.Name,
277− APIKey: s.apiKeyValue,
278− Type: provider.Type,
279− BaseURL: provider.APIEndpoint,
280− }
281− return s, tea.Sequence(
282− util.CmdHandler(models.APIKeyStateChangeMsg{
283− State: models.APIKeyInputStateVerifying,
284− }),
285− func() tea.Msg {
286− start := time.Now()
287− err := providerConfig.TestConnection(config.Get().Resolver())
288− // intentionally wait for at least 750ms to make sure the user sees the spinner
289− elapsed := time.Since(start)
290− if elapsed < 750*time.Millisecond {
291− time.Sleep(750*time.Millisecond - elapsed)
292− }
293− if err == nil {
294− s.isAPIKeyValid = true
295− return models.APIKeyStateChangeMsg{
296− State: models.APIKeyInputStateVerified,
297− }
298− }
299− return models.APIKeyStateChangeMsg{
300− State: models.APIKeyInputStateError,
301− }
302− },
303− )
304− case s.needsProjectInit:
305− return s, s.initializeProject()
306− }
307− case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
308− if s.needsAPIKey {
309− u, cmd := s.apiKeyInput.Update(msg)
310− s.apiKeyInput = u.(*models.APIKeyInput)
311− return s, cmd
312− }
313− if s.needsProjectInit {
314− s.selectedNo = !s.selectedNo
315− return s, nil
316− }
317− case key.Matches(msg, s.keyMap.Yes):
318− if s.needsAPIKey {
319− u, cmd := s.apiKeyInput.Update(msg)
320− s.apiKeyInput = u.(*models.APIKeyInput)
321− return s, cmd
322− }
323− if s.isOnboarding {
324− u, cmd := s.modelList.Update(msg)
325− s.modelList = u
326− return s, cmd
327− }
328− if s.needsProjectInit {
329− s.selectedNo = false
330− return s, s.initializeProject()
331− }
332− case key.Matches(msg, s.keyMap.No):
333− if s.needsAPIKey {
334− u, cmd := s.apiKeyInput.Update(msg)
335− s.apiKeyInput = u.(*models.APIKeyInput)
336− return s, cmd
337− }
338− if s.isOnboarding {
339− u, cmd := s.modelList.Update(msg)
340− s.modelList = u
341− return s, cmd
342− }
343− if s.needsProjectInit {
344− s.selectedNo = true
345− return s, s.initializeProject()
346− }
347− default:
348− switch {
349− case s.showHyperDeviceFlow:
350− u, cmd := s.hyperDeviceFlow.Update(msg)
351− s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
352− return s, cmd
353− case s.showCopilotDeviceFlow:
354− u, cmd := s.copilotDeviceFlow.Update(msg)
355− s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
356− return s, cmd
357− case s.needsAPIKey:
358− u, cmd := s.apiKeyInput.Update(msg)
359− s.apiKeyInput = u.(*models.APIKeyInput)
360− return s, cmd
361− case s.isOnboarding:
362− u, cmd := s.modelList.Update(msg)
363− s.modelList = u
364− return s, cmd
365− }
366− }
367− case tea.PasteMsg:
368− switch {
369− case s.showHyperDeviceFlow:
370− u, cmd := s.hyperDeviceFlow.Update(msg)
371− s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
372− return s, cmd
373− case s.showCopilotDeviceFlow:
374− u, cmd := s.copilotDeviceFlow.Update(msg)
375− s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
376− return s, cmd
377− case s.needsAPIKey:
378− u, cmd := s.apiKeyInput.Update(msg)
379− s.apiKeyInput = u.(*models.APIKeyInput)
380− return s, cmd
381− case s.isOnboarding:
382− var cmd tea.Cmd
383− s.modelList, cmd = s.modelList.Update(msg)
384− return s, cmd
385− }
386− case spinner.TickMsg:
387− switch {
388− case s.showHyperDeviceFlow:
389− u, cmd := s.hyperDeviceFlow.Update(msg)
390− s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
391− return s, cmd
392− case s.showCopilotDeviceFlow:
393− u, cmd := s.copilotDeviceFlow.Update(msg)
394− s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
395− return s, cmd
396− default:
397− u, cmd := s.apiKeyInput.Update(msg)
398− s.apiKeyInput = u.(*models.APIKeyInput)
399− return s, cmd
400− }
401− }
402− return s, nil
403−}
404−
405−func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
406− if s.selectedModel == nil {
407− return nil
408− }
409−
410− cfg := config.Get()
411− err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
412− if err != nil {
413− return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
414− }
415−
416− // Reset API key state and continue with model selection
417− s.needsAPIKey = false
418− cmd := s.setPreferredModel(*s.selectedModel)
419− s.isOnboarding = false
420− s.selectedModel = nil
421− s.isAPIKeyValid = false
422−
423− if close {
424− return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
425− }
426− return cmd
427−}
428−
429−func (s *splashCmp) initializeProject() tea.Cmd {
430− s.needsProjectInit = false
431−
432− if err := config.MarkProjectInitialized(); err != nil {
433− return util.ReportError(err)
434− }
435− var cmds []tea.Cmd
436−
437− cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
438− if !s.selectedNo {
439− initPrompt, err := agent.InitializePrompt(*config.Get())
440− if err != nil {
441− return util.ReportError(err)
442− }
443− cmds = append(cmds,
444− util.CmdHandler(chat.SessionClearedMsg{}),
445− util.CmdHandler(chat.SendMsg{
446− Text: initPrompt,
447− }),
448− )
449− }
450− return tea.Sequence(cmds...)
451−}
452−
453−func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
454− cfg := config.Get()
455− model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
456− if model == nil {
457− return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
458− }
459−
460− selectedModel := config.SelectedModel{
461− Model: selectedItem.Model.ID,
462− Provider: string(selectedItem.Provider.ID),
463− ReasoningEffort: model.DefaultReasoningEffort,
464− MaxTokens: model.DefaultMaxTokens,
465− }
466−
467− err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
468− if err != nil {
469− return util.ReportError(err)
470− }
471−
472− // Now lets automatically setup the small model
473− knownProvider, err := s.getProvider(selectedItem.Provider.ID)
474− if err != nil {
475− return util.ReportError(err)
476− }
477− if knownProvider == nil {
478− // for local provider we just use the same model
479− err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
480− if err != nil {
481− return util.ReportError(err)
482− }
483− } else {
484− smallModel := knownProvider.DefaultSmallModelID
485− model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
486− // should never happen
487− if model == nil {
488− err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
489− if err != nil {
490− return util.ReportError(err)
491− }
492− return nil
493− }
494− smallSelectedModel := config.SelectedModel{
495− Model: smallModel,
496− Provider: string(selectedItem.Provider.ID),
497− ReasoningEffort: model.DefaultReasoningEffort,
498− MaxTokens: model.DefaultMaxTokens,
499− }
500− err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
501− if err != nil {
502− return util.ReportError(err)
503− }
504− }
505− cfg.SetupAgents()
506− return nil
507−}
508−
509−func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
510− cfg := config.Get()
511− providers, err := config.Providers(cfg)
512− if err != nil {
513− return nil, err
514− }
515− for _, p := range providers {
516− if p.ID == providerID {
517− return &p, nil
518− }
519− }
520− return nil, nil
521−}
522−
523−func (s *splashCmp) isProviderConfigured(providerID string) bool {
524− cfg := config.Get()
525− if _, ok := cfg.Providers.Get(providerID); ok {
526− return true
527− }
528− return false
529−}
530−
531−func (s *splashCmp) View() string {
532− t := styles.CurrentTheme()
533− var content string
534−
535− switch {
536− case s.showHyperDeviceFlow:
537− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
538− hyperView := s.hyperDeviceFlow.View()
539− hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
540− lipgloss.JoinVertical(
541− lipgloss.Left,
542− t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"),
543− hyperView,
544− ),
545− )
546− content = lipgloss.JoinVertical(
547− lipgloss.Left,
548− s.logoRendered,
549− hyperSelector,
550− )
551− case s.showCopilotDeviceFlow:
552− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
553− copilotView := s.copilotDeviceFlow.View()
554− copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
555− lipgloss.JoinVertical(
556− lipgloss.Left,
557− t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"),
558− copilotView,
559− ),
560− )
561− content = lipgloss.JoinVertical(
562− lipgloss.Left,
563− s.logoRendered,
564− copilotSelector,
565− )
566− case s.needsAPIKey:
567− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
568− apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
569− apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
570− lipgloss.JoinVertical(
571− lipgloss.Left,
572− apiKeyView,
573− ),
574− )
575− content = lipgloss.JoinVertical(
576− lipgloss.Left,
577− s.logoRendered,
578− apiKeySelector,
579− )
580− case s.isOnboarding:
581− modelListView := s.modelList.View()
582− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
583− modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
584− lipgloss.JoinVertical(
585− lipgloss.Left,
586− t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("To start, let’s choose a provider and model."),
587− "",
588− modelListView,
589− ),
590− )
591− content = lipgloss.JoinVertical(
592− lipgloss.Left,
593− s.logoRendered,
594− modelSelector,
595− )
596− case s.needsProjectInit:
597− titleStyle := t.S().Base.Foreground(t.FgBase)
598− pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2)
599− bodyStyle := t.S().Base.Foreground(t.FgMuted)
600− shortcutStyle := t.S().Base.Foreground(t.Success)
601−
602− initFile := config.Get().Options.InitializeAs
603− initText := lipgloss.JoinVertical(
604− lipgloss.Left,
605− titleStyle.Render("Would you like to initialize this project?"),
606− "",
607− pathStyle.Render(s.cwd()),
608− "",
609− bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
610− bodyStyle.Render(fmt.Sprintf("result into an %s file which serves as general context.", initFile)),
611− "",
612− bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
613− "",
614− bodyStyle.Render("Would you like to initialize now?"),
615− )
616−
617− yesButton := core.SelectableButton(core.ButtonOpts{
618− Text: "Yep!",
619− UnderlineIndex: 0,
620− Selected: !s.selectedNo,
621− })
622−
623− noButton := core.SelectableButton(core.ButtonOpts{
624− Text: "Nope",
625− UnderlineIndex: 0,
626− Selected: s.selectedNo,
627− })
628−
629− buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton)
630− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
631−
632− initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
633− lipgloss.JoinVertical(
634− lipgloss.Left,
635− initText,
636− "",
637− buttons,
638− ),
639− )
640−
641− content = lipgloss.JoinVertical(
642− lipgloss.Left,
643− s.logoRendered,
644− "",
645− initContent,
646− )
647− default:
648− parts := []string{
649− s.logoRendered,
650− s.infoSection(),
651− }
652− content = lipgloss.JoinVertical(lipgloss.Left, parts...)
653− }
654−
655− return t.S().Base.
656− Width(s.width).
657− Height(s.height).
658− PaddingTop(SplashScreenPaddingY).
659− PaddingBottom(SplashScreenPaddingY).
660− Render(content)
661−}
662−
663−func (s *splashCmp) Cursor() *tea.Cursor {
664− switch {
665− case s.needsAPIKey:
666− cursor := s.apiKeyInput.Cursor()
667− if cursor != nil {
668− return s.moveCursor(cursor)
669− }
670− case s.isOnboarding:
671− cursor := s.modelList.Cursor()
672− if cursor != nil {
673− return s.moveCursor(cursor)
674− }
675− }
676− return nil
677−}
678−
679−func (s *splashCmp) isSmallScreen() bool {
680− // Consider a screen small if either the width is less than 40 or if the
681− // height is less than 20
682− return s.width < 55 || s.height < 20
683−}
684−
685−func (s *splashCmp) infoSection() string {
686− t := styles.CurrentTheme()
687− infoStyle := t.S().Base.PaddingLeft(2)
688− if s.isSmallScreen() {
689− infoStyle = infoStyle.MarginTop(1)
690− }
691− return infoStyle.Render(
692− lipgloss.JoinVertical(
693− lipgloss.Left,
694− s.cwdPart(),
695− "",
696− s.currentModelBlock(),
697− "",
698− lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
699− "",
700− ),
701− )
702−}
703−
704−func (s *splashCmp) logoBlock() string {
705− t := styles.CurrentTheme()
706− logoStyle := t.S().Base.Padding(0, 2).Width(s.width)
707− if s.isSmallScreen() {
708− // If the width is too small, render a smaller version of the logo
709− // NOTE: 20 is not correct because [splashCmp.height] is not the
710− // *actual* window height, instead, it is the height of the splash
711− // component and that depends on other variables like compact mode and
712− // the height of the editor.
713− return logoStyle.Render(
714− logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()),
715− )
716− }
717− return logoStyle.Render(
718− logo.Render(version.Version, false, logo.Opts{
719− FieldColor: t.Primary,
720− TitleColorA: t.Secondary,
721− TitleColorB: t.Primary,
722− CharmColor: t.Secondary,
723− VersionColor: t.Primary,
724− Width: s.width - logoStyle.GetHorizontalFrameSize(),
725− }),
726− )
727−}
728−
729−func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
730− if cursor == nil {
731− return nil
732− }
733− // Calculate the correct Y offset based on current state
734− logoHeight := lipgloss.Height(s.logoRendered)
735− if s.needsAPIKey {
736− infoSectionHeight := lipgloss.Height(s.infoSection())
737− baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
738− remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
739− offset := baseOffset + remainingHeight
740− cursor.Y += offset
741− cursor.X += 1
742− } else if s.isOnboarding {
743− offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
744− cursor.Y += offset
745− cursor.X += 1
746− }
747−
748− return cursor
749−}
750−
751−func (s *splashCmp) logoGap() int {
752− if s.height > 35 {
753− return LogoGap
754− }
755− return 0
756−}
757−
758−// Bindings implements SplashPage.
759−func (s *splashCmp) Bindings() []key.Binding {
760− switch {
761− case s.needsAPIKey:
762− return []key.Binding{
763− s.keyMap.Select,
764− s.keyMap.Back,
765− }
766− case s.isOnboarding:
767− return []key.Binding{
768− s.keyMap.Select,
769− s.keyMap.Next,
770− s.keyMap.Previous,
771− }
772− case s.needsProjectInit:
773− return []key.Binding{
774− s.keyMap.Select,
775− s.keyMap.Yes,
776− s.keyMap.No,
777− s.keyMap.Tab,
778− s.keyMap.LeftRight,
779− }
780− default:
781− return []key.Binding{}
782− }
783−}
784−
785−func (s *splashCmp) getMaxInfoWidth() int {
786− return min(s.width-2, 90) // 2 for left padding
787−}
788−
789−func (s *splashCmp) cwdPart() string {
790− t := styles.CurrentTheme()
791− maxWidth := s.getMaxInfoWidth()
792− return t.S().Muted.Width(maxWidth).Render(s.cwd())
793−}
794−
795−func (s *splashCmp) cwd() string {
796− return home.Short(config.Get().WorkingDir())
797−}
798−
799−func LSPList(maxWidth int) []string {
800− return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
801− MaxWidth: maxWidth,
802− ShowSection: false,
803− })
804−}
805−
806−func (s *splashCmp) lspBlock() string {
807− t := styles.CurrentTheme()
808− maxWidth := s.getMaxInfoWidth() / 2
809− section := t.S().Subtle.Render("LSPs")
810− lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
811− return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
812− lipgloss.JoinVertical(
813− lipgloss.Left,
814− lspList...,
815− ),
816− )
817−}
818−
819−func MCPList(maxWidth int) []string {
820− return mcp.RenderMCPList(mcp.RenderOptions{
821− MaxWidth: maxWidth,
822− ShowSection: false,
823− })
824−}
825−
826−func (s *splashCmp) mcpBlock() string {
827− t := styles.CurrentTheme()
828− maxWidth := s.getMaxInfoWidth() / 2
829− section := t.S().Subtle.Render("MCPs")
830− mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
831− return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
832− lipgloss.JoinVertical(
833− lipgloss.Left,
834− mcpList...,
835− ),
836− )
837−}
838−
839−func (s *splashCmp) currentModelBlock() string {
840− cfg := config.Get()
841− agentCfg := cfg.Agents[config.AgentCoder]
842− model := config.Get().GetModelByType(agentCfg.Model)
843− if model == nil {
844− return ""
845− }
846− t := styles.CurrentTheme()
847− modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
848− modelName := t.S().Text.Render(model.Name)
849− modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
850− parts := []string{
851− modelInfo,
852− }
853−
854− return lipgloss.JoinVertical(
855− lipgloss.Left,
856− parts...,
857− )
858−}
859−
860−func (s *splashCmp) IsShowingAPIKey() bool {
861− return s.needsAPIKey
862−}
863−
864−func (s *splashCmp) IsAPIKeyValid() bool {
865− return s.isAPIKeyValid
866−}
867−
868−func (s *splashCmp) IsShowingHyperOAuth2() bool {
869− return s.showHyperDeviceFlow
870−}
871−
872−func (s *splashCmp) IsShowingCopilotOAuth2() bool {
873− return s.showCopilotDeviceFlow
874−}
internal/tui/components/chat/todos/todos.godeleted−67
@@ -1,67 +0,0 @@
1−package todos
2−
3−import (
4− "slices"
5− "strings"
6−
7− "charm.land/lipgloss/v2"
8− "github.com/charmbracelet/crush/internal/session"
9− "github.com/charmbracelet/crush/internal/tui/styles"
10− "github.com/charmbracelet/x/ansi"
11−)
12−
13−func sortTodos(todos []session.Todo) {
14− slices.SortStableFunc(todos, func(a, b session.Todo) int {
15− return statusOrder(a.Status) - statusOrder(b.Status)
16− })
17−}
18−
19−func statusOrder(s session.TodoStatus) int {
20− switch s {
21− case session.TodoStatusCompleted:
22− return 0
23− case session.TodoStatusInProgress:
24− return 1
25− default:
26− return 2
27− }
28−}
29−
30−func FormatTodosList(todos []session.Todo, inProgressIcon string, t *styles.Theme, width int) string {
31− if len(todos) == 0 {
32− return ""
33− }
34−
35− sorted := make([]session.Todo, len(todos))
36− copy(sorted, todos)
37− sortTodos(sorted)
38−
39− var lines []string
40− for _, todo := range sorted {
41− var prefix string
42− var textStyle lipgloss.Style
43−
44− switch todo.Status {
45− case session.TodoStatusCompleted:
46− prefix = t.S().Base.Foreground(t.Green).Render(styles.TodoCompletedIcon) + " "
47− textStyle = t.S().Base.Foreground(t.FgBase)
48− case session.TodoStatusInProgress:
49− prefix = t.S().Base.Foreground(t.GreenDark).Render(inProgressIcon + " ")
50− textStyle = t.S().Base.Foreground(t.FgBase)
51− default:
52− prefix = t.S().Base.Foreground(t.FgMuted).Render(styles.TodoPendingIcon) + " "
53− textStyle = t.S().Base.Foreground(t.FgBase)
54− }
55−
56− text := todo.Content
57− if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
58− text = todo.ActiveForm
59− }
60− line := prefix + textStyle.Render(text)
61− line = ansi.Truncate(line, width, "…")
62−
63− lines = append(lines, line)
64− }
65−
66− return strings.Join(lines, "\n")
67−}
internal/tui/components/completions/completions.godeleted−308
@@ -1,308 +0,0 @@
1−package completions
2−
3−import (
4− "strings"
5−
6− "charm.land/bubbles/v2/key"
7− tea "charm.land/bubbletea/v2"
8− "charm.land/lipgloss/v2"
9− "github.com/charmbracelet/crush/internal/tui/exp/list"
10− "github.com/charmbracelet/crush/internal/tui/styles"
11− "github.com/charmbracelet/crush/internal/tui/util"
12−)
13−
14−const maxCompletionsHeight = 10
15−
16−type Completion struct {
17− Title string // The title of the completion item
18− Value any // The value of the completion item
19−}
20−
21−type OpenCompletionsMsg struct {
22− Completions []Completion
23− X int // X position for the completions popup
24− Y int // Y position for the completions popup
25− MaxResults int // Maximum number of results to render, 0 for no limit
26−}
27−
28−type FilterCompletionsMsg struct {
29− Query string // The query to filter completions
30− Reopen bool
31− X int // X position for the completions popup
32− Y int // Y position for the completions popup
33−}
34−
35−type RepositionCompletionsMsg struct {
36− X, Y int
37−}
38−
39−type CompletionsClosedMsg struct{}
40−
41−type CompletionsOpenedMsg struct{}
42−
43−type CloseCompletionsMsg struct{}
44−
45−type SelectCompletionMsg struct {
46− Value any // The value of the selected completion item
47− Insert bool
48−}
49−
50−type Completions interface {
51− util.Model
52− Open() bool
53− Query() string // Returns the current filter query
54− KeyMap() KeyMap
55− Position() (int, int) // Returns the X and Y position of the completions popup
56− Width() int
57− Height() int
58−}
59−
60−type listModel = list.FilterableList[list.CompletionItem[any]]
61−
62−type completionsCmp struct {
63− wWidth int // The window width
64− wHeight int // The window height
65− width int
66− lastWidth int
67− height int // Height of the completions component`
68− x, xorig int // X position for the completions popup
69− y int // Y position for the completions popup
70− open bool // Indicates if the completions are open
71− keyMap KeyMap
72−
73− list listModel
74− query string // The current filter query
75−}
76−
77−func New() Completions {
78− completionsKeyMap := DefaultKeyMap()
79− keyMap := list.DefaultKeyMap()
80− keyMap.Up.SetEnabled(false)
81− keyMap.Down.SetEnabled(false)
82− keyMap.HalfPageDown.SetEnabled(false)
83− keyMap.HalfPageUp.SetEnabled(false)
84− keyMap.Home.SetEnabled(false)
85− keyMap.End.SetEnabled(false)
86− keyMap.UpOneItem = completionsKeyMap.Up
87− keyMap.DownOneItem = completionsKeyMap.Down
88−
89− l := list.NewFilterableList(
90− []list.CompletionItem[any]{},
91− list.WithFilterInputHidden(),
92− list.WithFilterListOptions(
93− list.WithDirectionBackward(),
94− list.WithKeyMap(keyMap),
95− ),
96− )
97− return &completionsCmp{
98− width: 0,
99− height: maxCompletionsHeight,
100− list: l,
101− query: "",
102− keyMap: completionsKeyMap,
103− }
104−}
105−
106−// Init implements Completions.
107−func (c *completionsCmp) Init() tea.Cmd {
108− return tea.Sequence(
109− c.list.Init(),
110− c.list.SetSize(c.width, c.height),
111− )
112−}
113−
114−// Update implements Completions.
115−func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
116− switch msg := msg.(type) {
117− case tea.WindowSizeMsg:
118− c.wWidth, c.wHeight = msg.Width, msg.Height
119− return c, nil
120− case tea.KeyPressMsg:
121− switch {
122− case key.Matches(msg, c.keyMap.Up):
123− u, cmd := c.list.Update(msg)
124− c.list = u.(listModel)
125− return c, cmd
126−
127− case key.Matches(msg, c.keyMap.Down):
128− d, cmd := c.list.Update(msg)
129− c.list = d.(listModel)
130− return c, cmd
131− case key.Matches(msg, c.keyMap.UpInsert):
132− s := c.list.SelectedItem()
133− if s == nil {
134− return c, nil
135− }
136− selectedItem := *s
137− c.list.SetSelected(selectedItem.ID())
138− return c, util.CmdHandler(SelectCompletionMsg{
139− Value: selectedItem.Value(),
140− Insert: true,
141− })
142− case key.Matches(msg, c.keyMap.DownInsert):
143− s := c.list.SelectedItem()
144− if s == nil {
145− return c, nil
146− }
147− selectedItem := *s
148− c.list.SetSelected(selectedItem.ID())
149− return c, util.CmdHandler(SelectCompletionMsg{
150− Value: selectedItem.Value(),
151− Insert: true,
152− })
153− case key.Matches(msg, c.keyMap.Select):
154− s := c.list.SelectedItem()
155− if s == nil {
156− return c, nil
157− }
158− selectedItem := *s
159− c.open = false // Close completions after selection
160− return c, util.CmdHandler(SelectCompletionMsg{
161− Value: selectedItem.Value(),
162− })
163− case key.Matches(msg, c.keyMap.Cancel):
164− return c, util.CmdHandler(CloseCompletionsMsg{})
165− }
166− case RepositionCompletionsMsg:
167− c.x, c.y = msg.X, msg.Y
168− c.adjustPosition()
169− case CloseCompletionsMsg:
170− c.open = false
171− return c, util.CmdHandler(CompletionsClosedMsg{})
172− case OpenCompletionsMsg:
173− c.open = true
174− c.query = ""
175− c.x, c.xorig = msg.X, msg.X
176− c.y = msg.Y
177− items := []list.CompletionItem[any]{}
178− t := styles.CurrentTheme()
179− for _, completion := range msg.Completions {
180− item := list.NewCompletionItem(
181− completion.Title,
182− completion.Value,
183− list.WithCompletionBackgroundColor(t.BgSubtle),
184− )
185− items = append(items, item)
186− }
187− width := listWidth(items)
188− if len(items) == 0 {
189− width = listWidth(c.list.Items())
190− }
191− if c.x+width >= c.wWidth {
192− c.x = c.wWidth - width - 1
193− }
194− c.width = width
195− c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
196− c.list.SetResultsSize(msg.MaxResults)
197− return c, tea.Batch(
198− c.list.SetItems(items),
199− c.list.SetSize(c.width, c.height),
200− util.CmdHandler(CompletionsOpenedMsg{}),
201− )
202− case FilterCompletionsMsg:
203− if !c.open && !msg.Reopen {
204− return c, nil
205− }
206− if msg.Query == c.query {
207− // PERF: if same query, don't need to filter again
208− return c, nil
209− }
210− if len(c.list.Items()) == 0 &&
211− len(msg.Query) > len(c.query) &&
212− strings.HasPrefix(msg.Query, c.query) {
213− // PERF: if c.query didn't match anything,
214− // AND msg.Query is longer than c.query,
215− // AND msg.Query is prefixed with c.query - which means
216− // that the user typed more chars after a 0 match,
217− // it won't match anything, so return earlier.
218− return c, nil
219− }
220− c.query = msg.Query
221− var cmds []tea.Cmd
222− cmds = append(cmds, c.list.Filter(msg.Query))
223− items := c.list.Items()
224− itemsLen := len(items)
225− c.xorig = msg.X
226− c.x, c.y = msg.X, msg.Y
227− c.adjustPosition()
228− cmds = append(cmds, c.list.SetSize(c.width, c.height))
229− if itemsLen == 0 {
230− cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
231− } else if msg.Reopen {
232− c.open = true
233− cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
234− }
235− return c, tea.Batch(cmds...)
236− }
237− return c, nil
238−}
239−
240−func (c *completionsCmp) adjustPosition() {
241− items := c.list.Items()
242− itemsLen := len(items)
243− width := listWidth(items)
244− c.lastWidth = c.width
245− if c.x < 0 || width < c.lastWidth {
246− c.x = c.xorig
247− } else if c.x+width >= c.wWidth {
248− c.x = c.wWidth - width - 1
249− }
250− c.width = width
251− c.height = max(min(maxCompletionsHeight, itemsLen), 1)
252−}
253−
254−// View implements Completions.
255−func (c *completionsCmp) View() string {
256− if !c.open || len(c.list.Items()) == 0 {
257− return ""
258− }
259−
260− t := styles.CurrentTheme()
261− style := t.S().Base.
262− Width(c.width).
263− Height(c.height).
264− Background(t.BgSubtle)
265−
266− return style.Render(c.list.View())
267−}
268−
269−// listWidth returns the width of the last 10 items in the list, which is used
270−// to determine the width of the completions popup.
271−// Note this only works for [completionItemCmp] items.
272−func listWidth(items []list.CompletionItem[any]) int {
273− var width int
274− if len(items) == 0 {
275− return width
276− }
277−
278− for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
279− itemWidth := lipgloss.Width(items[i].Text()) + 2 // +2 for padding
280− width = max(width, itemWidth)
281− }
282−
283− return width
284−}
285−
286−func (c *completionsCmp) Open() bool {
287− return c.open
288−}
289−
290−func (c *completionsCmp) Query() string {
291− return c.query
292−}
293−
294−func (c *completionsCmp) KeyMap() KeyMap {
295− return c.keyMap
296−}
297−
298−func (c *completionsCmp) Position() (int, int) {
299− return c.x, c.y - c.height
300−}
301−
302−func (c *completionsCmp) Width() int {
303− return c.width
304−}
305−
306−func (c *completionsCmp) Height() int {
307− return c.height
308−}
internal/tui/components/completions/keys.godeleted−72
@@ -1,72 +0,0 @@
1−package completions
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Down,
9− Up,
10− Select,
11− Cancel key.Binding
12− DownInsert,
13− UpInsert key.Binding
14−}
15−
16−func DefaultKeyMap() KeyMap {
17− return KeyMap{
18− Down: key.NewBinding(
19− key.WithKeys("down"),
20− key.WithHelp("down", "move down"),
21− ),
22− Up: key.NewBinding(
23− key.WithKeys("up"),
24− key.WithHelp("up", "move up"),
25− ),
26− Select: key.NewBinding(
27− key.WithKeys("enter", "tab", "ctrl+y"),
28− key.WithHelp("enter", "select"),
29− ),
30− Cancel: key.NewBinding(
31− key.WithKeys("esc", "alt+esc"),
32− key.WithHelp("esc", "cancel"),
33− ),
34− DownInsert: key.NewBinding(
35− key.WithKeys("ctrl+n"),
36− key.WithHelp("ctrl+n", "insert next"),
37− ),
38− UpInsert: key.NewBinding(
39− key.WithKeys("ctrl+p"),
40− key.WithHelp("ctrl+p", "insert previous"),
41− ),
42− }
43−}
44−
45−// KeyBindings implements layout.KeyMapProvider
46−func (k KeyMap) KeyBindings() []key.Binding {
47− return []key.Binding{
48− k.Down,
49− k.Up,
50− k.Select,
51− k.Cancel,
52− }
53−}
54−
55−// FullHelp implements help.KeyMap.
56−func (k KeyMap) FullHelp() [][]key.Binding {
57− m := [][]key.Binding{}
58− slice := k.KeyBindings()
59− for i := 0; i < len(slice); i += 4 {
60− end := min(i+4, len(slice))
61− m = append(m, slice[i:end])
62− }
63− return m
64−}
65−
66−// ShortHelp implements help.KeyMap.
67−func (k KeyMap) ShortHelp() []key.Binding {
68− return []key.Binding{
69− k.Up,
70− k.Down,
71− }
72−}
internal/tui/components/core/core.godeleted−207
@@ -1,207 +0,0 @@
1−package core
2−
3−import (
4− "image/color"
5− "strings"
6−
7− "charm.land/bubbles/v2/help"
8− "charm.land/bubbles/v2/key"
9− "charm.land/lipgloss/v2"
10− "github.com/alecthomas/chroma/v2"
11− "github.com/charmbracelet/crush/internal/tui/exp/diffview"
12− "github.com/charmbracelet/crush/internal/tui/styles"
13− "github.com/charmbracelet/x/ansi"
14−)
15−
16−type KeyMapHelp interface {
17− Help() help.KeyMap
18−}
19−
20−type simpleHelp struct {
21− shortList []key.Binding
22− fullList [][]key.Binding
23−}
24−
25−func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap {
26− return &simpleHelp{
27− shortList: shortList,
28− fullList: fullList,
29− }
30−}
31−
32−// FullHelp implements help.KeyMap.
33−func (s *simpleHelp) FullHelp() [][]key.Binding {
34− return s.fullList
35−}
36−
37−// ShortHelp implements help.KeyMap.
38−func (s *simpleHelp) ShortHelp() []key.Binding {
39− return s.shortList
40−}
41−
42−func Section(text string, width int) string {
43− t := styles.CurrentTheme()
44− char := "─"
45− length := lipgloss.Width(text) + 1
46− remainingWidth := width - length
47− lineStyle := t.S().Base.Foreground(t.Border)
48− if remainingWidth > 0 {
49− text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
50− }
51− return text
52−}
53−
54−func SectionWithInfo(text string, width int, info string) string {
55− t := styles.CurrentTheme()
56− char := "─"
57− length := lipgloss.Width(text) + 1
58− remainingWidth := width - length
59−
60− if info != "" {
61− remainingWidth -= lipgloss.Width(info) + 1 // 1 for the space before info
62− }
63− lineStyle := t.S().Base.Foreground(t.Border)
64− if remainingWidth > 0 {
65− text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + " " + info
66− }
67− return text
68−}
69−
70−func Title(title string, width int) string {
71− t := styles.CurrentTheme()
72− char := "╱"
73− length := lipgloss.Width(title) + 1
74− remainingWidth := width - length
75− titleStyle := t.S().Base.Foreground(t.Primary)
76− if remainingWidth > 0 {
77− lines := strings.Repeat(char, remainingWidth)
78− lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
79− title = titleStyle.Render(title) + " " + lines
80− }
81− return title
82−}
83−
84−type StatusOpts struct {
85− Icon string // if empty no icon will be shown
86− Title string
87− TitleColor color.Color
88− Description string
89− DescriptionColor color.Color
90− ExtraContent string // additional content to append after the description
91−}
92−
93−func Status(opts StatusOpts, width int) string {
94− t := styles.CurrentTheme()
95− icon := opts.Icon
96− title := opts.Title
97− titleColor := t.FgMuted
98− if opts.TitleColor != nil {
99− titleColor = opts.TitleColor
100− }
101− description := opts.Description
102− descriptionColor := t.FgSubtle
103− if opts.DescriptionColor != nil {
104− descriptionColor = opts.DescriptionColor
105− }
106− title = t.S().Base.Foreground(titleColor).Render(title)
107− if description != "" {
108− extraContentWidth := lipgloss.Width(opts.ExtraContent)
109− if extraContentWidth > 0 {
110− extraContentWidth += 1
111− }
112− description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
113− description = t.S().Base.Foreground(descriptionColor).Render(description)
114− }
115−
116− content := []string{}
117− if icon != "" {
118− content = append(content, icon)
119− }
120− content = append(content, title)
121− if description != "" {
122− content = append(content, description)
123− }
124− if opts.ExtraContent != "" {
125− content = append(content, opts.ExtraContent)
126− }
127−
128− return strings.Join(content, " ")
129−}
130−
131−type ButtonOpts struct {
132− Text string
133− UnderlineIndex int // Index of character to underline (0-based)
134− Selected bool // Whether this button is selected
135−}
136−
137−// SelectableButton creates a button with an underlined character and selection state
138−func SelectableButton(opts ButtonOpts) string {
139− t := styles.CurrentTheme()
140−
141− // Base style for the button
142− buttonStyle := t.S().Text
143−
144− // Apply selection styling
145− if opts.Selected {
146− buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary)
147− } else {
148− buttonStyle = buttonStyle.Background(t.BgSubtle)
149− }
150−
151− // Create the button text with underlined character
152− text := opts.Text
153− if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) {
154− before := text[:opts.UnderlineIndex]
155− underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1]
156− after := text[opts.UnderlineIndex+1:]
157−
158− message := buttonStyle.Render(before) +
159− buttonStyle.Underline(true).Render(underlined) +
160− buttonStyle.Render(after)
161−
162− return buttonStyle.Padding(0, 2).Render(message)
163− }
164−
165− // Fallback if no underline index specified
166− return buttonStyle.Padding(0, 2).Render(text)
167−}
168−
169−// SelectableButtons creates a horizontal row of selectable buttons
170−func SelectableButtons(buttons []ButtonOpts, spacing string) string {
171− if spacing == "" {
172− spacing = " "
173− }
174−
175− var parts []string
176− for i, button := range buttons {
177− parts = append(parts, SelectableButton(button))
178− if i < len(buttons)-1 {
179− parts = append(parts, spacing)
180− }
181− }
182−
183− return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
184−}
185−
186−// SelectableButtonsVertical creates a vertical row of selectable buttons
187−func SelectableButtonsVertical(buttons []ButtonOpts, spacing int) string {
188− var parts []string
189− for i, button := range buttons {
190− parts = append(parts, SelectableButton(button))
191− if i < len(buttons)-1 {
192− for range spacing {
193− parts = append(parts, "")
194− }
195− }
196− }
197−
198− return lipgloss.JoinVertical(lipgloss.Center, parts...)
199−}
200−
201−func DiffFormatter() *diffview.DiffView {
202− t := styles.CurrentTheme()
203− formatDiff := diffview.New()
204− style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
205− diff := formatDiff.ChromaStyle(style).Style(t.S().Diff).TabWidth(4)
206− return diff
207−}
internal/tui/components/core/layout/layout.godeleted−27
@@ -1,27 +0,0 @@
1−package layout
2−
3−import (
4− "charm.land/bubbles/v2/key"
5− tea "charm.land/bubbletea/v2"
6−)
7−
8−// TODO: move this to core
9−
10−type Focusable interface {
11− Focus() tea.Cmd
12− Blur() tea.Cmd
13− IsFocused() bool
14−}
15−
16−type Sizeable interface {
17− SetSize(width, height int) tea.Cmd
18− GetSize() (int, int)
19−}
20−
21−type Help interface {
22− Bindings() []key.Binding
23−}
24−
25−type Positional interface {
26− SetPosition(x, y int) tea.Cmd
27−}
internal/tui/components/core/status/status.godeleted−113
@@ -1,113 +0,0 @@
1−package status
2−
3−import (
4− "time"
5−
6− "charm.land/bubbles/v2/help"
7− tea "charm.land/bubbletea/v2"
8− "charm.land/lipgloss/v2"
9− "github.com/charmbracelet/crush/internal/tui/styles"
10− "github.com/charmbracelet/crush/internal/tui/util"
11− "github.com/charmbracelet/x/ansi"
12−)
13−
14−type StatusCmp interface {
15− util.Model
16− ToggleFullHelp()
17− SetKeyMap(keyMap help.KeyMap)
18−}
19−
20−type statusCmp struct {
21− info util.InfoMsg
22− width int
23− messageTTL time.Duration
24− help help.Model
25− keyMap help.KeyMap
26−}
27−
28−// clearMessageCmd is a command that clears status messages after a timeout
29−func (m *statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
30− return tea.Tick(ttl, func(time.Time) tea.Msg {
31− return util.ClearStatusMsg{}
32− })
33−}
34−
35−func (m *statusCmp) Init() tea.Cmd {
36− return nil
37−}
38−
39−func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
40− switch msg := msg.(type) {
41− case tea.WindowSizeMsg:
42− m.width = msg.Width
43− m.help.SetWidth(msg.Width - 2)
44− return m, nil
45−
46− // Handle status info
47− case util.InfoMsg:
48− m.info = msg
49− ttl := msg.TTL
50− if ttl == 0 {
51− ttl = m.messageTTL
52− }
53− return m, m.clearMessageCmd(ttl)
54− case util.ClearStatusMsg:
55− m.info = util.InfoMsg{}
56− }
57− return m, nil
58−}
59−
60−func (m *statusCmp) View() string {
61− t := styles.CurrentTheme()
62− status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
63− if m.info.Msg != "" {
64− status = m.infoMsg()
65− }
66− return status
67−}
68−
69−func (m *statusCmp) infoMsg() string {
70− t := styles.CurrentTheme()
71− message := ""
72− infoType := ""
73− switch m.info.Type {
74− case util.InfoTypeError:
75− infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
76− widthLeft := m.width - (lipgloss.Width(infoType) + 2)
77− info := ansi.Truncate(m.info.Msg, widthLeft, "…")
78− message = t.S().Base.Background(t.Error).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info)
79− case util.InfoTypeWarn:
80− infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
81− widthLeft := m.width - (lipgloss.Width(infoType) + 2)
82− info := ansi.Truncate(m.info.Msg, widthLeft, "…")
83− message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info)
84− default:
85− note := "OKAY!"
86− if m.info.Type == util.InfoTypeUpdate {
87− note = "HEY!"
88− }
89− infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render(note)
90− widthLeft := m.width - (lipgloss.Width(infoType) + 2)
91− info := ansi.Truncate(m.info.Msg, widthLeft, "…")
92− message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info)
93− }
94− return ansi.Truncate(infoType+message, m.width, "…")
95−}
96−
97−func (m *statusCmp) ToggleFullHelp() {
98− m.help.ShowAll = !m.help.ShowAll
99−}
100−
101−func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) {
102− m.keyMap = keyMap
103−}
104−
105−func NewStatusCmp() StatusCmp {
106− t := styles.CurrentTheme()
107− help := help.New()
108− help.Styles = t.S().Help
109− return &statusCmp{
110− messageTTL: 5 * time.Second,
111− help: help,
112− }
113−}
internal/tui/components/core/status_test.godeleted−144
@@ -1,144 +0,0 @@
1−package core_test
2−
3−import (
4− "fmt"
5− "image/color"
6− "testing"
7−
8− "github.com/charmbracelet/crush/internal/tui/components/core"
9− "github.com/charmbracelet/x/exp/golden"
10−)
11−
12−func TestStatus(t *testing.T) {
13− t.Parallel()
14−
15− tests := []struct {
16− name string
17− opts core.StatusOpts
18− width int
19− }{
20− {
21− name: "Default",
22− opts: core.StatusOpts{
23− Title: "Status",
24− Description: "Everything is working fine",
25− },
26− width: 80,
27− },
28− {
29− name: "WithCustomIcon",
30− opts: core.StatusOpts{
31− Icon: "✓",
32− Title: "Success",
33− Description: "Operation completed successfully",
34− },
35− width: 80,
36− },
37− {
38− name: "NoIcon",
39− opts: core.StatusOpts{
40− Title: "Info",
41− Description: "This status has no icon",
42− },
43− width: 80,
44− },
45− {
46− name: "WithColors",
47− opts: core.StatusOpts{
48− Icon: "⚠",
49− Title: "Warning",
50− TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
51− Description: "This is a warning message",
52− DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red
53− },
54− width: 80,
55− },
56− {
57− name: "WithExtraContent",
58− opts: core.StatusOpts{
59− Title: "Build",
60− Description: "Building project",
61− ExtraContent: "[2/5]",
62− },
63− width: 80,
64− },
65− {
66− name: "LongDescription",
67− opts: core.StatusOpts{
68− Title: "Processing",
69− Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping",
70− },
71− width: 60,
72− },
73− {
74− name: "NarrowWidth",
75− opts: core.StatusOpts{
76− Icon: "●",
77− Title: "Status",
78− Description: "Short message",
79− },
80− width: 30,
81− },
82− {
83− name: "VeryNarrowWidth",
84− opts: core.StatusOpts{
85− Icon: "●",
86− Title: "Test",
87− Description: "This will be truncated",
88− },
89− width: 20,
90− },
91− {
92− name: "EmptyDescription",
93− opts: core.StatusOpts{
94− Icon: "●",
95− Title: "Title Only",
96− },
97− width: 80,
98− },
99− {
100− name: "AllFieldsWithExtraContent",
101− opts: core.StatusOpts{
102− Icon: "🚀",
103− Title: "Deployment",
104− TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
105− Description: "Deploying to production environment",
106− DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray
107− ExtraContent: "v1.2.3",
108− },
109− width: 80,
110− },
111− }
112−
113− for _, tt := range tests {
114− t.Run(tt.name, func(t *testing.T) {
115− t.Parallel()
116−
117− output := core.Status(tt.opts, tt.width)
118− golden.RequireEqual(t, []byte(output))
119− })
120− }
121−}
122−
123−func TestStatusTruncation(t *testing.T) {
124− t.Parallel()
125−
126− opts := core.StatusOpts{
127− Icon: "●",
128− Title: "Very Long Title",
129− Description: "This is an extremely long description that definitely needs to be truncated",
130− ExtraContent: "[extra]",
131− }
132−
133− // Test different widths to ensure truncation works correctly
134− widths := []int{20, 30, 40, 50, 60}
135−
136− for _, width := range widths {
137− t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) {
138− t.Parallel()
139−
140− output := core.Status(opts, width)
141− golden.RequireEqual(t, []byte(output))
142− })
143− }
144−}
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.goldendeleted−1
@@ -1 +0,0 @@
1−🚀 Deployment Deploying to production environment v1.2.3
internal/tui/components/core/testdata/TestStatus/Default.goldendeleted−1
@@ -1 +0,0 @@
1−Status Everything is working fine
internal/tui/components/core/testdata/TestStatus/EmptyDescription.goldendeleted−1
@@ -1 +0,0 @@
1−● Title Only
internal/tui/components/core/testdata/TestStatus/LongDescription.goldendeleted−1
@@ -1 +0,0 @@
1−Processing This is a very long description that should be …
internal/tui/components/core/testdata/TestStatus/NarrowWidth.goldendeleted−1
@@ -1 +0,0 @@
1−● Status Short message
internal/tui/components/core/testdata/TestStatus/NoIcon.goldendeleted−1
@@ -1 +0,0 @@
1−Info This status has no icon
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.goldendeleted−1
@@ -1 +0,0 @@
1−● Test This will be…
internal/tui/components/core/testdata/TestStatus/WithColors.goldendeleted−1
@@ -1 +0,0 @@
1−⚠ Warning This is a warning message
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.goldendeleted−1
@@ -1 +0,0 @@
1−✓ Success Operation completed successfully
internal/tui/components/core/testdata/TestStatus/WithExtraContent.goldendeleted−1
@@ -1 +0,0 @@
1−Build Building project [2/5]
internal/tui/components/core/testdata/TestStatusTruncation/Width20.goldendeleted−1
@@ -1 +0,0 @@
1−● Very Long Title  [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width30.goldendeleted−1
@@ -1 +0,0 @@
1−● Very Long Title Thi… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width40.goldendeleted−1
@@ -1 +0,0 @@
1−● Very Long Title This is an ex… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width50.goldendeleted−1
@@ -1 +0,0 @@
1−● Very Long Title This is an extremely lo… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width60.goldendeleted−1
@@ -1 +0,0 @@
1−● Very Long Title This is an extremely long descrip… [extra]
internal/tui/components/dialogs/commands/arguments.godeleted−245
@@ -1,245 +0,0 @@
1−package commands
2−
3−import (
4− "cmp"
5−
6− "charm.land/bubbles/v2/help"
7− "charm.land/bubbles/v2/key"
8− "charm.land/bubbles/v2/textinput"
9− tea "charm.land/bubbletea/v2"
10− "charm.land/lipgloss/v2"
11− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
12− "github.com/charmbracelet/crush/internal/tui/styles"
13− "github.com/charmbracelet/crush/internal/tui/util"
14− "github.com/charmbracelet/crush/internal/uicmd"
15−)
16−
17−const (
18− argumentsDialogID dialogs.DialogID = "arguments"
19−)
20−
21−// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
22−type ShowArgumentsDialogMsg = uicmd.ShowArgumentsDialogMsg
23−
24−// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
25−type CloseArgumentsDialogMsg = uicmd.CloseArgumentsDialogMsg
26−
27−// CommandArgumentsDialog represents the commands dialog.
28−type CommandArgumentsDialog interface {
29− dialogs.DialogModel
30−}
31−
32−type commandArgumentsDialogCmp struct {
33− wWidth, wHeight int
34− width, height int
35−
36− inputs []textinput.Model
37− focused int
38− keys ArgumentsDialogKeyMap
39− arguments []Argument
40− help help.Model
41−
42− id string
43− title string
44− name string
45− description string
46−
47− onSubmit func(args map[string]string) tea.Cmd
48−}
49−
50−type Argument struct {
51− Name, Title, Description string
52− Required bool
53−}
54−
55−func NewCommandArgumentsDialog(
56− id, title, name, description string,
57− arguments []Argument,
58− onSubmit func(args map[string]string) tea.Cmd,
59−) CommandArgumentsDialog {
60− t := styles.CurrentTheme()
61− inputs := make([]textinput.Model, len(arguments))
62−
63− for i, arg := range arguments {
64− ti := textinput.New()
65− ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Title)
66− ti.SetWidth(40)
67− ti.SetVirtualCursor(false)
68− ti.Prompt = ""
69−
70− ti.SetStyles(t.S().TextInput)
71− // Only focus the first input initially
72− if i == 0 {
73− ti.Focus()
74− } else {
75− ti.Blur()
76− }
77−
78− inputs[i] = ti
79− }
80−
81− return &commandArgumentsDialogCmp{
82− inputs: inputs,
83− keys: DefaultArgumentsDialogKeyMap(),
84− id: id,
85− name: name,
86− title: title,
87− description: description,
88− arguments: arguments,
89− width: 60,
90− help: help.New(),
91− onSubmit: onSubmit,
92− }
93−}
94−
95−// Init implements CommandArgumentsDialog.
96−func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
97− return nil
98−}
99−
100−// Update implements CommandArgumentsDialog.
101−func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
102− switch msg := msg.(type) {
103− case tea.WindowSizeMsg:
104− c.wWidth = msg.Width
105− c.wHeight = msg.Height
106− c.width = min(90, c.wWidth)
107− c.height = min(15, c.wHeight)
108− for i := range c.inputs {
109− c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2))
110− }
111− case tea.KeyPressMsg:
112− switch {
113− case key.Matches(msg, c.keys.Close):
114− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
115− case key.Matches(msg, c.keys.Confirm):
116− if c.focused == len(c.inputs)-1 {
117− args := make(map[string]string)
118− for i, arg := range c.arguments {
119− value := c.inputs[i].Value()
120− args[arg.Name] = value
121− }
122− return c, tea.Sequence(
123− util.CmdHandler(dialogs.CloseDialogMsg{}),
124− c.onSubmit(args),
125− )
126− }
127− // Otherwise, move to the next input
128− c.inputs[c.focused].Blur()
129− c.focused++
130− c.inputs[c.focused].Focus()
131− case key.Matches(msg, c.keys.Next):
132− // Move to the next input
133− c.inputs[c.focused].Blur()
134− c.focused = (c.focused + 1) % len(c.inputs)
135− c.inputs[c.focused].Focus()
136− case key.Matches(msg, c.keys.Previous):
137− // Move to the previous input
138− c.inputs[c.focused].Blur()
139− c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs)
140− c.inputs[c.focused].Focus()
141− case key.Matches(msg, c.keys.Close):
142− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
143− default:
144− var cmd tea.Cmd
145− c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
146− return c, cmd
147− }
148− case tea.PasteMsg:
149− var cmd tea.Cmd
150− c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
151− return c, cmd
152− }
153− return c, nil
154−}
155−
156−// View implements CommandArgumentsDialog.
157−func (c *commandArgumentsDialogCmp) View() string {
158− t := styles.CurrentTheme()
159− baseStyle := t.S().Base
160−
161− title := lipgloss.NewStyle().
162− Foreground(t.Primary).
163− Bold(true).
164− Padding(0, 1).
165− Render(cmp.Or(c.title, c.name))
166−
167− promptName := t.S().Text.
168− Padding(0, 1).
169− Render(c.description)
170−
171− inputFields := make([]string, len(c.inputs))
172− for i, input := range c.inputs {
173− labelStyle := baseStyle.Padding(1, 1, 0, 1)
174−
175− if i == c.focused {
176− labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
177− } else {
178− labelStyle = labelStyle.Foreground(t.FgMuted)
179− }
180−
181− arg := c.arguments[i]
182− argName := cmp.Or(arg.Title, arg.Name)
183− if arg.Required {
184− argName += "*"
185− }
186− label := labelStyle.Render(argName + ":")
187−
188− field := t.S().Text.
189− Padding(0, 1).
190− Render(input.View())
191−
192− inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
193− }
194−
195− elements := []string{title, promptName}
196− elements = append(elements, inputFields...)
197−
198− c.help.ShowAll = false
199− helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
200− elements = append(elements, "", helpText)
201−
202− content := lipgloss.JoinVertical(lipgloss.Left, elements...)
203−
204− return baseStyle.Padding(1, 1, 0, 1).
205− Border(lipgloss.RoundedBorder()).
206− BorderForeground(t.BorderFocus).
207− Width(c.width).
208− Render(content)
209−}
210−
211−func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor {
212− if len(c.inputs) == 0 {
213− return nil
214− }
215− cursor := c.inputs[c.focused].Cursor()
216− if cursor != nil {
217− cursor = c.moveCursor(cursor)
218− }
219− return cursor
220−}
221−
222−const (
223− headerHeight = 3
224− itemHeight = 3
225− paddingHorizontal = 3
226−)
227−
228−func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
229− row, col := c.Position()
230− offset := row + headerHeight + (1+c.focused)*itemHeight
231− cursor.Y += offset
232− cursor.X = cursor.X + col + paddingHorizontal
233− return cursor
234−}
235−
236−func (c *commandArgumentsDialogCmp) Position() (int, int) {
237− row := (c.wHeight / 2) - (c.height / 2)
238− col := (c.wWidth / 2) - (c.width / 2)
239− return row, col
240−}
241−
242−// ID implements CommandArgumentsDialog.
243−func (c *commandArgumentsDialogCmp) ID() dialogs.DialogID {
244− return argumentsDialogID
245−}
internal/tui/components/dialogs/commands/commands.godeleted−479
@@ -1,479 +0,0 @@
1−package commands
2−
3−import (
4− "fmt"
5− "os"
6− "slices"
7− "strings"
8−
9− "charm.land/bubbles/v2/help"
10− "charm.land/bubbles/v2/key"
11− tea "charm.land/bubbletea/v2"
12− "charm.land/lipgloss/v2"
13−
14− "github.com/charmbracelet/crush/internal/agent"
15− "github.com/charmbracelet/crush/internal/agent/tools/mcp"
16− "github.com/charmbracelet/crush/internal/config"
17− "github.com/charmbracelet/crush/internal/csync"
18− "github.com/charmbracelet/crush/internal/pubsub"
19− "github.com/charmbracelet/crush/internal/tui/components/chat"
20− "github.com/charmbracelet/crush/internal/tui/components/core"
21− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
22− "github.com/charmbracelet/crush/internal/tui/exp/list"
23− "github.com/charmbracelet/crush/internal/tui/styles"
24− "github.com/charmbracelet/crush/internal/tui/util"
25− "github.com/charmbracelet/crush/internal/uicmd"
26−)
27−
28−const (
29− CommandsDialogID dialogs.DialogID = "commands"
30−
31− defaultWidth int = 70
32−)
33−
34−type commandType = uicmd.CommandType
35−
36−const (
37− SystemCommands = uicmd.SystemCommands
38− UserCommands = uicmd.UserCommands
39− MCPPrompts = uicmd.MCPPrompts
40−)
41−
42−type listModel = list.FilterableList[list.CompletionItem[Command]]
43−
44−// Command represents a command that can be executed
45−type (
46− Command = uicmd.Command
47− CommandRunCustomMsg = uicmd.CommandRunCustomMsg
48− ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg
49−)
50−
51−// CommandsDialog represents the commands dialog.
52−type CommandsDialog interface {
53− dialogs.DialogModel
54−}
55−
56−type commandDialogCmp struct {
57− width int
58− wWidth int // Width of the terminal window
59− wHeight int // Height of the terminal window
60−
61− commandList listModel
62− keyMap CommandsDialogKeyMap
63− help help.Model
64− selected commandType // Selected SystemCommands, UserCommands, or MCPPrompts
65− userCommands []Command // User-defined commands
66− mcpPrompts *csync.Slice[Command] // MCP prompts
67− sessionID string // Current session ID
68−}
69−
70−type (
71− SwitchSessionsMsg struct{}
72− NewSessionsMsg struct{}
73− SwitchModelMsg struct{}
74− QuitMsg struct{}
75− OpenFilePickerMsg struct{}
76− ToggleHelpMsg struct{}
77− ToggleCompactModeMsg struct{}
78− ToggleThinkingMsg struct{}
79− OpenReasoningDialogMsg struct{}
80− OpenExternalEditorMsg struct{}
81− ToggleYoloModeMsg struct{}
82− CompactMsg struct {
83− SessionID string
84− }
85−)
86−
87−func NewCommandDialog(sessionID string) CommandsDialog {
88− keyMap := DefaultCommandsDialogKeyMap()
89− listKeyMap := list.DefaultKeyMap()
90− listKeyMap.Down.SetEnabled(false)
91− listKeyMap.Up.SetEnabled(false)
92− listKeyMap.DownOneItem = keyMap.Next
93− listKeyMap.UpOneItem = keyMap.Previous
94−
95− t := styles.CurrentTheme()
96− inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
97− commandList := list.NewFilterableList(
98− []list.CompletionItem[Command]{},
99− list.WithFilterInputStyle(inputStyle),
100− list.WithFilterListOptions(
101− list.WithKeyMap(listKeyMap),
102− list.WithWrapNavigation(),
103− list.WithResizeByList(),
104− ),
105− )
106− help := help.New()
107− help.Styles = t.S().Help
108− return &commandDialogCmp{
109− commandList: commandList,
110− width: defaultWidth,
111− keyMap: DefaultCommandsDialogKeyMap(),
112− help: help,
113− selected: SystemCommands,
114− sessionID: sessionID,
115− mcpPrompts: csync.NewSlice[Command](),
116− }
117−}
118−
119−func (c *commandDialogCmp) Init() tea.Cmd {
120− commands, err := uicmd.LoadCustomCommands()
121− if err != nil {
122− return util.ReportError(err)
123− }
124− c.userCommands = commands
125− c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
126− return c.setCommandType(c.selected)
127−}
128−
129−func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
130− switch msg := msg.(type) {
131− case tea.WindowSizeMsg:
132− c.wWidth = msg.Width
133− c.wHeight = msg.Height
134− return c, tea.Batch(
135− c.setCommandType(c.selected),
136− c.commandList.SetSize(c.listWidth(), c.listHeight()),
137− )
138− case pubsub.Event[mcp.Event]:
139− // Reload MCP prompts when MCP state changes
140− if msg.Type == pubsub.UpdatedEvent {
141− c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
142− // If we're currently viewing MCP prompts, refresh the list
143− if c.selected == MCPPrompts {
144− return c, c.setCommandType(MCPPrompts)
145− }
146− return c, nil
147− }
148− case tea.KeyPressMsg:
149− switch {
150− case key.Matches(msg, c.keyMap.Select):
151− selectedItem := c.commandList.SelectedItem()
152− if selectedItem == nil {
153− return c, nil // No item selected, do nothing
154− }
155− command := (*selectedItem).Value()
156− return c, tea.Sequence(
157− util.CmdHandler(dialogs.CloseDialogMsg{}),
158− command.Handler(command),
159− )
160− case key.Matches(msg, c.keyMap.Tab):
161− if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
162− return c, nil
163− }
164− return c, c.setCommandType(c.next())
165− case key.Matches(msg, c.keyMap.Close):
166− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
167− default:
168− u, cmd := c.commandList.Update(msg)
169− c.commandList = u.(listModel)
170− return c, cmd
171− }
172− }
173− return c, nil
174−}
175−
176−func (c *commandDialogCmp) next() commandType {
177− switch c.selected {
178− case SystemCommands:
179− if len(c.userCommands) > 0 {
180− return UserCommands
181− }
182− if c.mcpPrompts.Len() > 0 {
183− return MCPPrompts
184− }
185− fallthrough
186− case UserCommands:
187− if c.mcpPrompts.Len() > 0 {
188− return MCPPrompts
189− }
190− fallthrough
191− case MCPPrompts:
192− return SystemCommands
193− default:
194− return SystemCommands
195− }
196−}
197−
198−func (c *commandDialogCmp) View() string {
199− t := styles.CurrentTheme()
200− listView := c.commandList
201− radio := c.commandTypeRadio()
202−
203− header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
204− if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
205− header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
206− }
207− content := lipgloss.JoinVertical(
208− lipgloss.Left,
209− header,
210− listView.View(),
211− "",
212− t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
213− )
214− return c.style().Render(content)
215−}
216−
217−func (c *commandDialogCmp) Cursor() *tea.Cursor {
218− if cursor, ok := c.commandList.(util.Cursor); ok {
219− cursor := cursor.Cursor()
220− if cursor != nil {
221− cursor = c.moveCursor(cursor)
222− }
223− return cursor
224− }
225− return nil
226−}
227−
228−func (c *commandDialogCmp) commandTypeRadio() string {
229− t := styles.CurrentTheme()
230−
231− fn := func(i commandType) string {
232− if i == c.selected {
233− return "◉ " + i.String()
234− }
235− return "○ " + i.String()
236− }
237−
238− parts := []string{
239− fn(SystemCommands),
240− }
241− if len(c.userCommands) > 0 {
242− parts = append(parts, fn(UserCommands))
243− }
244− if c.mcpPrompts.Len() > 0 {
245− parts = append(parts, fn(MCPPrompts))
246− }
247− return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
248−}
249−
250−func (c *commandDialogCmp) listWidth() int {
251− return defaultWidth - 2 // 4 for padding
252−}
253−
254−func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
255− c.selected = commandType
256−
257− var commands []Command
258− switch c.selected {
259− case SystemCommands:
260− commands = c.defaultCommands()
261− case UserCommands:
262− commands = c.userCommands
263− case MCPPrompts:
264− commands = slices.Collect(c.mcpPrompts.Seq())
265− }
266−
267− commandItems := []list.CompletionItem[Command]{}
268− for _, cmd := range commands {
269− opts := []list.CompletionItemOption{
270− list.WithCompletionID(cmd.ID),
271− }
272− if cmd.Shortcut != "" {
273− opts = append(
274− opts,
275− list.WithCompletionShortcut(cmd.Shortcut),
276− )
277− }
278− commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
279− }
280− return c.commandList.SetItems(commandItems)
281−}
282−
283−func (c *commandDialogCmp) listHeight() int {
284− listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
285− return min(listHeigh, c.wHeight/2)
286−}
287−
288−func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
289− row, col := c.Position()
290− offset := row + 3
291− cursor.Y += offset
292− cursor.X = cursor.X + col + 2
293− return cursor
294−}
295−
296−func (c *commandDialogCmp) style() lipgloss.Style {
297− t := styles.CurrentTheme()
298− return t.S().Base.
299− Width(c.width).
300− Border(lipgloss.RoundedBorder()).
301− BorderForeground(t.BorderFocus)
302−}
303−
304−func (c *commandDialogCmp) Position() (int, int) {
305− row := c.wHeight/4 - 2 // just a bit above the center
306− col := c.wWidth / 2
307− col -= c.width / 2
308− return row, col
309−}
310−
311−func (c *commandDialogCmp) defaultCommands() []Command {
312− commands := []Command{
313− {
314− ID: "new_session",
315− Title: "New Session",
316− Description: "start a new session",
317− Shortcut: "ctrl+n",
318− Handler: func(cmd Command) tea.Cmd {
319− return util.CmdHandler(NewSessionsMsg{})
320− },
321− },
322− {
323− ID: "switch_session",
324− Title: "Switch Session",
325− Description: "Switch to a different session",
326− Shortcut: "ctrl+s",
327− Handler: func(cmd Command) tea.Cmd {
328− return util.CmdHandler(SwitchSessionsMsg{})
329− },
330− },
331− {
332− ID: "switch_model",
333− Title: "Switch Model",
334− Description: "Switch to a different model",
335− Shortcut: "ctrl+l",
336− Handler: func(cmd Command) tea.Cmd {
337− return util.CmdHandler(SwitchModelMsg{})
338− },
339− },
340− }
341−
342− // Only show compact command if there's an active session
343− if c.sessionID != "" {
344− commands = append(commands, Command{
345− ID: "Summarize",
346− Title: "Summarize Session",
347− Description: "Summarize the current session and create a new one with the summary",
348− Handler: func(cmd Command) tea.Cmd {
349− return util.CmdHandler(CompactMsg{
350− SessionID: c.sessionID,
351− })
352− },
353− })
354− }
355−
356− // Add reasoning toggle for models that support it
357− cfg := config.Get()
358− if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
359− providerCfg := cfg.GetProviderForModel(agentCfg.Model)
360− model := cfg.GetModelByType(agentCfg.Model)
361− if providerCfg != nil && model != nil && model.CanReason {
362− selectedModel := cfg.Models[agentCfg.Model]
363−
364− // Anthropic models: thinking toggle
365− if model.CanReason && len(model.ReasoningLevels) == 0 {
366− status := "Enable"
367− if selectedModel.Think {
368− status = "Disable"
369− }
370− commands = append(commands, Command{
371− ID: "toggle_thinking",
372− Title: status + " Thinking Mode",
373− Description: "Toggle model thinking for reasoning-capable models",
374− Handler: func(cmd Command) tea.Cmd {
375− return util.CmdHandler(ToggleThinkingMsg{})
376− },
377− })
378− }
379−
380− // OpenAI models: reasoning effort dialog
381− if len(model.ReasoningLevels) > 0 {
382− commands = append(commands, Command{
383− ID: "select_reasoning_effort",
384− Title: "Select Reasoning Effort",
385− Description: "Choose reasoning effort level (low/medium/high)",
386− Handler: func(cmd Command) tea.Cmd {
387− return util.CmdHandler(OpenReasoningDialogMsg{})
388− },
389− })
390− }
391− }
392− }
393− // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
394− if c.wWidth > 120 && c.sessionID != "" {
395− commands = append(commands, Command{
396− ID: "toggle_sidebar",
397− Title: "Toggle Sidebar",
398− Description: "Toggle between compact and normal layout",
399− Handler: func(cmd Command) tea.Cmd {
400− return util.CmdHandler(ToggleCompactModeMsg{})
401− },
402− })
403− }
404− if c.sessionID != "" {
405− agentCfg := config.Get().Agents[config.AgentCoder]
406− model := config.Get().GetModelByType(agentCfg.Model)
407− if model.SupportsImages {
408− commands = append(commands, Command{
409− ID: "file_picker",
410− Title: "Open File Picker",
411− Shortcut: "ctrl+f",
412− Description: "Open file picker",
413− Handler: func(cmd Command) tea.Cmd {
414− return util.CmdHandler(OpenFilePickerMsg{})
415− },
416− })
417− }
418− }
419−
420− // Add external editor command if $EDITOR is available
421− if os.Getenv("EDITOR") != "" {
422− commands = append(commands, Command{
423− ID: "open_external_editor",
424− Title: "Open External Editor",
425− Shortcut: "ctrl+o",
426− Description: "Open external editor to compose message",
427− Handler: func(cmd Command) tea.Cmd {
428− return util.CmdHandler(OpenExternalEditorMsg{})
429− },
430− })
431− }
432−
433− return append(commands, []Command{
434− {
435− ID: "toggle_yolo",
436− Title: "Toggle Yolo Mode",
437− Description: "Toggle yolo mode",
438− Handler: func(cmd Command) tea.Cmd {
439− return util.CmdHandler(ToggleYoloModeMsg{})
440− },
441− },
442− {
443− ID: "toggle_help",
444− Title: "Toggle Help",
445− Shortcut: "ctrl+g",
446− Description: "Toggle help",
447− Handler: func(cmd Command) tea.Cmd {
448− return util.CmdHandler(ToggleHelpMsg{})
449− },
450− },
451− {
452− ID: "init",
453− Title: "Initialize Project",
454− Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
455− Handler: func(cmd Command) tea.Cmd {
456− initPrompt, err := agent.InitializePrompt(*config.Get())
457− if err != nil {
458− return util.ReportError(err)
459− }
460− return util.CmdHandler(chat.SendMsg{
461− Text: initPrompt,
462− })
463− },
464− },
465− {
466− ID: "quit",
467− Title: "Quit",
468− Description: "Quit",
469− Shortcut: "ctrl+c",
470− Handler: func(cmd Command) tea.Cmd {
471− return util.CmdHandler(QuitMsg{})
472− },
473− },
474− }...)
475−}
476−
477−func (c *commandDialogCmp) ID() dialogs.DialogID {
478− return CommandsDialogID
479−}
internal/tui/components/dialogs/commands/keys.godeleted−133
@@ -1,133 +0,0 @@
1−package commands
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type CommandsDialogKeyMap struct {
8− Select,
9− Next,
10− Previous,
11− Tab,
12− Close key.Binding
13−}
14−
15−func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
16− return CommandsDialogKeyMap{
17− Select: key.NewBinding(
18− key.WithKeys("enter", "ctrl+y"),
19− key.WithHelp("enter", "confirm"),
20− ),
21− Next: key.NewBinding(
22− key.WithKeys("down", "ctrl+n"),
23− key.WithHelp("↓", "next item"),
24− ),
25− Previous: key.NewBinding(
26− key.WithKeys("up", "ctrl+p"),
27− key.WithHelp("↑", "previous item"),
28− ),
29− Tab: key.NewBinding(
30− key.WithKeys("tab"),
31− key.WithHelp("tab", "switch selection"),
32− ),
33− Close: key.NewBinding(
34− key.WithKeys("esc", "alt+esc"),
35− key.WithHelp("esc", "cancel"),
36− ),
37− }
38−}
39−
40−// KeyBindings implements layout.KeyMapProvider
41−func (k CommandsDialogKeyMap) KeyBindings() []key.Binding {
42− return []key.Binding{
43− k.Select,
44− k.Next,
45− k.Previous,
46− k.Tab,
47− k.Close,
48− }
49−}
50−
51−// FullHelp implements help.KeyMap.
52−func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
53− m := [][]key.Binding{}
54− slice := k.KeyBindings()
55− for i := 0; i < len(slice); i += 4 {
56− end := min(i+4, len(slice))
57− m = append(m, slice[i:end])
58− }
59− return m
60−}
61−
62−// ShortHelp implements help.KeyMap.
63−func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
64− return []key.Binding{
65− k.Tab,
66− key.NewBinding(
67− key.WithKeys("down", "up"),
68− key.WithHelp("↑↓", "choose"),
69− ),
70− k.Select,
71− k.Close,
72− }
73−}
74−
75−type ArgumentsDialogKeyMap struct {
76− Confirm key.Binding
77− Next key.Binding
78− Previous key.Binding
79− Close key.Binding
80−}
81−
82−func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
83− return ArgumentsDialogKeyMap{
84− Confirm: key.NewBinding(
85− key.WithKeys("enter"),
86− key.WithHelp("enter", "confirm"),
87− ),
88−
89− Next: key.NewBinding(
90− key.WithKeys("tab", "down"),
91− key.WithHelp("tab/↓", "next"),
92− ),
93− Previous: key.NewBinding(
94− key.WithKeys("shift+tab", "up"),
95− key.WithHelp("shift+tab/↑", "previous"),
96− ),
97− Close: key.NewBinding(
98− key.WithKeys("esc", "alt+esc"),
99− key.WithHelp("esc", "cancel"),
100− ),
101− }
102−}
103−
104−// KeyBindings implements layout.KeyMapProvider
105−func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding {
106− return []key.Binding{
107− k.Confirm,
108− k.Next,
109− k.Previous,
110− k.Close,
111− }
112−}
113−
114−// FullHelp implements help.KeyMap.
115−func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding {
116− m := [][]key.Binding{}
117− slice := k.KeyBindings()
118− for i := 0; i < len(slice); i += 4 {
119− end := min(i+4, len(slice))
120− m = append(m, slice[i:end])
121− }
122− return m
123−}
124−
125−// ShortHelp implements help.KeyMap.
126−func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
127− return []key.Binding{
128− k.Confirm,
129− k.Next,
130− k.Previous,
131− k.Close,
132− }
133−}
internal/tui/components/dialogs/copilot/device_flow.godeleted−281
@@ -1,281 +0,0 @@
1−// Package copilot provides the dialog for Copilot device flow authentication.
2−package copilot
3−
4−import (
5− "context"
6− "fmt"
7− "time"
8−
9− "charm.land/bubbles/v2/spinner"
10− tea "charm.land/bubbletea/v2"
11− "charm.land/lipgloss/v2"
12− "github.com/charmbracelet/crush/internal/oauth"
13− "github.com/charmbracelet/crush/internal/oauth/copilot"
14− "github.com/charmbracelet/crush/internal/tui/styles"
15− "github.com/charmbracelet/crush/internal/tui/util"
16− "github.com/pkg/browser"
17−)
18−
19−// DeviceFlowState represents the current state of the device flow.
20−type DeviceFlowState int
21−
22−const (
23− DeviceFlowStateDisplay DeviceFlowState = iota
24− DeviceFlowStateSuccess
25− DeviceFlowStateError
26− DeviceFlowStateUnavailable
27−)
28−
29−// DeviceAuthInitiatedMsg is sent when the device auth is initiated
30−// successfully.
31−type DeviceAuthInitiatedMsg struct {
32− deviceCode *copilot.DeviceCode
33−}
34−
35−// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
36−type DeviceFlowCompletedMsg struct {
37− Token *oauth.Token
38−}
39−
40−// DeviceFlowErrorMsg is sent when the device flow encounters an error.
41−type DeviceFlowErrorMsg struct {
42− Error error
43−}
44−
45−// DeviceFlow handles the Copilot device flow authentication.
46−type DeviceFlow struct {
47− State DeviceFlowState
48− width int
49− deviceCode *copilot.DeviceCode
50− token *oauth.Token
51− cancelFunc context.CancelFunc
52− spinner spinner.Model
53−}
54−
55−// NewDeviceFlow creates a new device flow component.
56−func NewDeviceFlow() *DeviceFlow {
57− s := spinner.New()
58− s.Spinner = spinner.Dot
59− s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
60− return &DeviceFlow{
61− State: DeviceFlowStateDisplay,
62− spinner: s,
63− }
64−}
65−
66−// Init initializes the device flow by calling the device auth API and starting polling.
67−func (d *DeviceFlow) Init() tea.Cmd {
68− return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
69−}
70−
71−// Update handles messages and state transitions.
72−func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
73− var cmd tea.Cmd
74− d.spinner, cmd = d.spinner.Update(msg)
75−
76− switch msg := msg.(type) {
77− case DeviceAuthInitiatedMsg:
78− return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
79− case DeviceFlowCompletedMsg:
80− d.State = DeviceFlowStateSuccess
81− d.token = msg.Token
82− return d, nil
83− case DeviceFlowErrorMsg:
84− switch msg.Error {
85− case copilot.ErrNotAvailable:
86− d.State = DeviceFlowStateUnavailable
87− default:
88− d.State = DeviceFlowStateError
89− }
90− return d, nil
91− }
92−
93− return d, cmd
94−}
95−
96−// View renders the device flow dialog.
97−func (d *DeviceFlow) View() string {
98− t := styles.CurrentTheme()
99−
100− whiteStyle := lipgloss.NewStyle().Foreground(t.White)
101− primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
102− greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
103− linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
104− errorStyle := lipgloss.NewStyle().Foreground(t.Error)
105− mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
106−
107− switch d.State {
108− case DeviceFlowStateDisplay:
109− if d.deviceCode == nil {
110− return lipgloss.NewStyle().
111− Margin(0, 1).
112− Render(
113− greenStyle.Render(d.spinner.View()) +
114− mutedStyle.Render("Initializing..."),
115− )
116− }
117−
118− instructions := lipgloss.NewStyle().
119− Margin(1, 1, 0, 1).
120− Width(d.width - 2).
121− Render(
122− whiteStyle.Render("Press ") +
123− primaryStyle.Render("enter") +
124− whiteStyle.Render(" to copy the code below and open the browser."),
125− )
126−
127− codeBox := lipgloss.NewStyle().
128− Width(d.width-2).
129− Height(7).
130− Align(lipgloss.Center, lipgloss.Center).
131− Background(t.BgBaseLighter).
132− Margin(1).
133− Render(
134− lipgloss.NewStyle().
135− Bold(true).
136− Foreground(t.White).
137− Render(d.deviceCode.UserCode),
138− )
139−
140− uri := d.deviceCode.VerificationURI
141− link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri)
142− url := mutedStyle.
143− Margin(0, 1).
144− Width(d.width - 2).
145− Render("Browser not opening? Refer to\n" + link)
146−
147− waiting := greenStyle.
148− Width(d.width-2).
149− Margin(1, 1, 0, 1).
150− Render(d.spinner.View() + "Verifying...")
151−
152− return lipgloss.JoinVertical(
153− lipgloss.Left,
154− instructions,
155− codeBox,
156− url,
157− waiting,
158− )
159−
160− case DeviceFlowStateSuccess:
161− return greenStyle.Margin(0, 1).Render("Authentication successful!")
162−
163− case DeviceFlowStateError:
164− return lipgloss.NewStyle().
165− Margin(0, 1).
166− Width(d.width - 2).
167− Render(errorStyle.Render("Authentication failed."))
168−
169− case DeviceFlowStateUnavailable:
170− message := lipgloss.NewStyle().
171− Margin(0, 1).
172− Width(d.width - 2).
173− Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
174− freeMessage := lipgloss.NewStyle().
175− Margin(0, 1).
176− Width(d.width - 2).
177− Render("You may be able to request free access if eligible. For more information, see:")
178− return lipgloss.JoinVertical(
179− lipgloss.Left,
180− message,
181− "",
182− linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL),
183− "",
184− freeMessage,
185− "",
186− linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL),
187− )
188−
189− default:
190− return ""
191− }
192−}
193−
194−// SetWidth sets the width of the dialog.
195−func (d *DeviceFlow) SetWidth(w int) {
196− d.width = w
197−}
198−
199−// Cursor hides the cursor.
200−func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
201−
202−// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
203−func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
204− switch d.State {
205− case DeviceFlowStateDisplay:
206− return tea.Sequence(
207− tea.SetClipboard(d.deviceCode.UserCode),
208− func() tea.Msg {
209− if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil {
210− return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
211− }
212− return nil
213− },
214− util.ReportInfo("Code copied and URL opened"),
215− )
216− case DeviceFlowStateUnavailable:
217− return tea.Sequence(
218− func() tea.Msg {
219− if err := browser.OpenURL(copilot.SignupURL); err != nil {
220− return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
221− }
222− return nil
223− },
224− util.ReportInfo("Code copied and URL opened"),
225− )
226− default:
227− return nil
228− }
229−}
230−
231−// CopyCode copies just the user code to the clipboard.
232−func (d *DeviceFlow) CopyCode() tea.Cmd {
233− if d.State != DeviceFlowStateDisplay {
234− return nil
235− }
236− return tea.Sequence(
237− tea.SetClipboard(d.deviceCode.UserCode),
238− util.ReportInfo("Code copied to clipboard"),
239− )
240−}
241−
242−// Cancel cancels the device flow polling.
243−func (d *DeviceFlow) Cancel() {
244− if d.cancelFunc != nil {
245− d.cancelFunc()
246− }
247−}
248−
249−func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
250− ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
251− defer cancel()
252−
253− deviceCode, err := copilot.RequestDeviceCode(ctx)
254− if err != nil {
255− return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
256− }
257−
258− d.deviceCode = deviceCode
259−
260− return DeviceAuthInitiatedMsg{
261− deviceCode: d.deviceCode,
262− }
263−}
264−
265−// startPolling starts polling for the device token.
266−func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd {
267− return func() tea.Msg {
268− ctx, cancel := context.WithCancel(context.Background())
269− d.cancelFunc = cancel
270−
271− token, err := copilot.PollForToken(ctx, deviceCode)
272− if err != nil {
273− if ctx.Err() != nil {
274− return nil // cancelled, don't report error.
275− }
276− return DeviceFlowErrorMsg{Error: err}
277− }
278−
279− return DeviceFlowCompletedMsg{Token: token}
280− }
281−}
internal/tui/components/dialogs/dialogs.godeleted−165
@@ -1,165 +0,0 @@
1−package dialogs
2−
3−import (
4− "slices"
5−
6− tea "charm.land/bubbletea/v2"
7− "charm.land/lipgloss/v2"
8− "github.com/charmbracelet/crush/internal/tui/util"
9−)
10−
11−type DialogID string
12−
13−// DialogModel represents a dialog component that can be displayed.
14−type DialogModel interface {
15− util.Model
16− Position() (int, int)
17− ID() DialogID
18−}
19−
20−// CloseCallback allows dialogs to perform cleanup when closed.
21−type CloseCallback interface {
22− Close() tea.Cmd
23−}
24−
25−// OpenDialogMsg is sent to open a new dialog with specified dimensions.
26−type OpenDialogMsg struct {
27− Model DialogModel
28−}
29−
30−// CloseDialogMsg is sent to close the topmost dialog.
31−type CloseDialogMsg struct{}
32−
33−// DialogCmp manages a stack of dialogs with keyboard navigation.
34−type DialogCmp interface {
35− util.Model
36−
37− Dialogs() []DialogModel
38− HasDialogs() bool
39− GetLayers() []*lipgloss.Layer
40− ActiveModel() util.Model
41− ActiveDialogID() DialogID
42−}
43−
44−type dialogCmp struct {
45− width, height int
46− dialogs []DialogModel
47− idMap map[DialogID]int
48− keyMap KeyMap
49−}
50−
51−// NewDialogCmp creates a new dialog manager.
52−func NewDialogCmp() DialogCmp {
53− return dialogCmp{
54− dialogs: []DialogModel{},
55− keyMap: DefaultKeyMap(),
56− idMap: make(map[DialogID]int),
57− }
58−}
59−
60−func (d dialogCmp) Init() tea.Cmd {
61− return nil
62−}
63−
64−// Update handles dialog lifecycle and forwards messages to the active dialog.
65−func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
66− switch msg := msg.(type) {
67− case tea.WindowSizeMsg:
68− var cmds []tea.Cmd
69− d.width = msg.Width
70− d.height = msg.Height
71− for i := range d.dialogs {
72− u, cmd := d.dialogs[i].Update(msg)
73− d.dialogs[i] = u.(DialogModel)
74− cmds = append(cmds, cmd)
75− }
76− return d, tea.Batch(cmds...)
77− case OpenDialogMsg:
78− return d.handleOpen(msg)
79− case CloseDialogMsg:
80− if len(d.dialogs) == 0 {
81− return d, nil
82− }
83− inx := len(d.dialogs) - 1
84− dialog := d.dialogs[inx]
85− delete(d.idMap, dialog.ID())
86− d.dialogs = d.dialogs[:len(d.dialogs)-1]
87− if closeable, ok := dialog.(CloseCallback); ok {
88− return d, closeable.Close()
89− }
90− return d, nil
91− }
92− if d.HasDialogs() {
93− lastIndex := len(d.dialogs) - 1
94− u, cmd := d.dialogs[lastIndex].Update(msg)
95− d.dialogs[lastIndex] = u.(DialogModel)
96− return d, cmd
97− }
98− return d, nil
99−}
100−
101−func (d dialogCmp) View() string {
102− return ""
103−}
104−
105−func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) {
106− if d.HasDialogs() {
107− dialog := d.dialogs[len(d.dialogs)-1]
108− if dialog.ID() == msg.Model.ID() {
109− return d, nil // Do not open a dialog if it's already the topmost one
110− }
111− if dialog.ID() == "quit" {
112− return d, nil // Do not open dialogs on top of quit
113− }
114− }
115− // if the dialog is already in the stack make it the last item
116− if _, ok := d.idMap[msg.Model.ID()]; ok {
117− existing := d.dialogs[d.idMap[msg.Model.ID()]]
118− // Reuse the model so we keep the state
119− msg.Model = existing
120− d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
121− }
122− d.idMap[msg.Model.ID()] = len(d.dialogs)
123− d.dialogs = append(d.dialogs, msg.Model)
124− var cmds []tea.Cmd
125− cmd := msg.Model.Init()
126− cmds = append(cmds, cmd)
127− _, cmd = msg.Model.Update(tea.WindowSizeMsg{
128− Width: d.width,
129− Height: d.height,
130− })
131− cmds = append(cmds, cmd)
132− return d, tea.Batch(cmds...)
133−}
134−
135−func (d dialogCmp) Dialogs() []DialogModel {
136− return d.dialogs
137−}
138−
139−func (d dialogCmp) ActiveModel() util.Model {
140− if len(d.dialogs) == 0 {
141− return nil
142− }
143− return d.dialogs[len(d.dialogs)-1]
144−}
145−
146−func (d dialogCmp) ActiveDialogID() DialogID {
147− if len(d.dialogs) == 0 {
148− return ""
149− }
150− return d.dialogs[len(d.dialogs)-1].ID()
151−}
152−
153−func (d dialogCmp) GetLayers() []*lipgloss.Layer {
154− layers := []*lipgloss.Layer{}
155− for _, dialog := range d.Dialogs() {
156− dialogView := dialog.View()
157− row, col := dialog.Position()
158− layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
159− }
160− return layers
161−}
162−
163−func (d dialogCmp) HasDialogs() bool {
164− return len(d.dialogs) > 0
165−}
internal/tui/components/dialogs/filepicker/filepicker.godeleted−260
@@ -1,260 +0,0 @@
1−package filepicker
2−
3−import (
4− "fmt"
5− "net/http"
6− "os"
7− "path/filepath"
8− "strings"
9−
10− "charm.land/bubbles/v2/filepicker"
11− "charm.land/bubbles/v2/help"
12− "charm.land/bubbles/v2/key"
13− tea "charm.land/bubbletea/v2"
14− "charm.land/lipgloss/v2"
15− "github.com/charmbracelet/crush/internal/home"
16− "github.com/charmbracelet/crush/internal/message"
17− "github.com/charmbracelet/crush/internal/tui/components/core"
18− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
19− "github.com/charmbracelet/crush/internal/tui/components/image"
20− "github.com/charmbracelet/crush/internal/tui/styles"
21− "github.com/charmbracelet/crush/internal/tui/util"
22−)
23−
24−const (
25− MaxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
26− FilePickerID = "filepicker"
27− fileSelectionHeight = 10
28− previewHeight = 20
29−)
30−
31−type FilePickedMsg struct {
32− Attachment message.Attachment
33−}
34−
35−type FilePicker interface {
36− dialogs.DialogModel
37−}
38−
39−type model struct {
40− wWidth int
41− wHeight int
42− width int
43− filePicker filepicker.Model
44− highlightedFile string
45− image image.Model
46− keyMap KeyMap
47− help help.Model
48−}
49−
50−var AllowedTypes = []string{".jpg", ".jpeg", ".png"}
51−
52−func NewFilePickerCmp(workingDir string) FilePicker {
53− t := styles.CurrentTheme()
54− fp := filepicker.New()
55− fp.AllowedTypes = AllowedTypes
56−
57− if workingDir != "" {
58− fp.CurrentDirectory = workingDir
59− } else {
60− // Fallback to current working directory, then home directory
61− if cwd, err := os.Getwd(); err == nil {
62− fp.CurrentDirectory = cwd
63− } else {
64− fp.CurrentDirectory = home.Dir()
65− }
66− }
67−
68− fp.ShowPermissions = false
69− fp.ShowSize = false
70− fp.AutoHeight = false
71− fp.Styles = t.S().FilePicker
72− fp.Cursor = ""
73− fp.SetHeight(fileSelectionHeight)
74−
75− image := image.New(1, 1, "")
76−
77− help := help.New()
78− help.Styles = t.S().Help
79− return &model{
80− filePicker: fp,
81− image: image,
82− keyMap: DefaultKeyMap(),
83− help: help,
84− }
85−}
86−
87−func (m *model) Init() tea.Cmd {
88− return m.filePicker.Init()
89−}
90−
91−func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
92− switch msg := msg.(type) {
93− case tea.WindowSizeMsg:
94− m.wWidth = msg.Width
95− m.wHeight = msg.Height
96− m.width = min(70, m.wWidth)
97− styles := m.filePicker.Styles
98− styles.Directory = styles.Directory.Width(m.width - 4)
99− styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
100− styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
101− styles.File = styles.File.Width(m.width)
102− m.filePicker.Styles = styles
103− return m, nil
104− case tea.KeyPressMsg:
105− if key.Matches(msg, m.keyMap.Close) {
106− return m, util.CmdHandler(dialogs.CloseDialogMsg{})
107− }
108− if key.Matches(msg, m.filePicker.KeyMap.Back) {
109− // make sure we don't go back if we are at the home directory
110− if m.filePicker.CurrentDirectory == home.Dir() {
111− return m, nil
112− }
113− }
114− }
115−
116− var cmd tea.Cmd
117− var cmds []tea.Cmd
118− m.filePicker, cmd = m.filePicker.Update(msg)
119− cmds = append(cmds, cmd)
120− if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
121− w, h := m.imagePreviewSize()
122− cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
123− cmds = append(cmds, cmd)
124− }
125− m.highlightedFile = m.currentImage()
126−
127− // Did the user select a file?
128− if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
129− // Get the path of the selected file.
130− return m, tea.Sequence(
131− util.CmdHandler(dialogs.CloseDialogMsg{}),
132− func() tea.Msg {
133− isFileLarge, err := IsFileTooBig(path, MaxAttachmentSize)
134− if err != nil {
135− return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
136− }
137− if isFileLarge {
138− return util.ReportError(fmt.Errorf("file too large, max 5MB"))
139− }
140−
141− content, err := os.ReadFile(path)
142− if err != nil {
143− return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
144− }
145−
146− mimeBufferSize := min(512, len(content))
147− mimeType := http.DetectContentType(content[:mimeBufferSize])
148− fileName := filepath.Base(path)
149− attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
150− return FilePickedMsg{
151− Attachment: attachment,
152− }
153− },
154− )
155− }
156− m.image, cmd = m.image.Update(msg)
157− cmds = append(cmds, cmd)
158− return m, tea.Batch(cmds...)
159−}
160−
161−func (m *model) View() string {
162− t := styles.CurrentTheme()
163−
164− strs := []string{
165− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
166− }
167−
168− // hide image preview if the terminal is too small
169− if x, y := m.imagePreviewSize(); x > 0 && y > 0 {
170− strs = append(strs, m.imagePreview())
171− }
172−
173− strs = append(
174− strs,
175− m.filePicker.View(),
176− t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
177− )
178−
179− content := lipgloss.JoinVertical(
180− lipgloss.Left,
181− strs...,
182− )
183− return m.style().Render(content)
184−}
185−
186−func (m *model) currentImage() string {
187− for _, ext := range m.filePicker.AllowedTypes {
188− if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
189− return m.filePicker.HighlightedPath()
190− }
191− }
192− return ""
193−}
194−
195−func (m *model) imagePreview() string {
196− const padding = 2
197−
198− t := styles.CurrentTheme()
199− w, h := m.imagePreviewSize()
200−
201− if m.currentImage() == "" {
202− imgPreview := t.S().Base.
203− Width(w - padding).
204− Height(h - padding).
205− Background(t.BgOverlay)
206−
207− return m.imagePreviewStyle().Render(imgPreview.Render())
208− }
209−
210− return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
211−}
212−
213−func (m *model) imagePreviewStyle() lipgloss.Style {
214− t := styles.CurrentTheme()
215− return t.S().Base.Padding(1, 1, 1, 1)
216−}
217−
218−func (m *model) imagePreviewSize() (int, int) {
219− if m.wHeight-fileSelectionHeight-8 > previewHeight {
220− return m.width - 4, previewHeight
221− }
222− return 0, 0
223−}
224−
225−func (m *model) style() lipgloss.Style {
226− t := styles.CurrentTheme()
227− return t.S().Base.
228− Width(m.width).
229− Border(lipgloss.RoundedBorder()).
230− BorderForeground(t.BorderFocus)
231−}
232−
233−// ID implements FilePicker.
234−func (m *model) ID() dialogs.DialogID {
235− return FilePickerID
236−}
237−
238−// Position implements FilePicker.
239−func (m *model) Position() (int, int) {
240− _, imageHeight := m.imagePreviewSize()
241− dialogHeight := fileSelectionHeight + imageHeight + 4
242− row := (m.wHeight - dialogHeight) / 2
243−
244− col := m.wWidth / 2
245− col -= m.width / 2
246− return row, col
247−}
248−
249−func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
250− fileInfo, err := os.Stat(filePath)
251− if err != nil {
252− return false, fmt.Errorf("error getting file info: %w", err)
253− }
254−
255− if fileInfo.Size() > sizeLimit {
256− return true, nil
257− }
258−
259− return false, nil
260−}
internal/tui/components/dialogs/filepicker/keys.godeleted−80
@@ -1,80 +0,0 @@
1−package filepicker
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−// KeyMap defines keyboard bindings for dialog management.
8−type KeyMap struct {
9− Select,
10− Down,
11− Up,
12− Forward,
13− Backward,
14− Close key.Binding
15−}
16−
17−func DefaultKeyMap() KeyMap {
18− return KeyMap{
19− Select: key.NewBinding(
20− key.WithKeys("enter"),
21− key.WithHelp("enter", "accept"),
22− ),
23− Down: key.NewBinding(
24− key.WithKeys("down", "j"),
25− key.WithHelp("down/j", "move down"),
26− ),
27− Up: key.NewBinding(
28− key.WithKeys("up", "k"),
29− key.WithHelp("up/k", "move up"),
30− ),
31− Forward: key.NewBinding(
32− key.WithKeys("right", "l"),
33− key.WithHelp("right/l", "move forward"),
34− ),
35− Backward: key.NewBinding(
36− key.WithKeys("left", "h"),
37− key.WithHelp("left/h", "move backward"),
38− ),
39−
40− Close: key.NewBinding(
41− key.WithKeys("esc", "alt+esc"),
42− key.WithHelp("esc", "close/exit"),
43− ),
44− }
45−}
46−
47−// KeyBindings implements layout.KeyMapProvider
48−func (k KeyMap) KeyBindings() []key.Binding {
49− return []key.Binding{
50− k.Select,
51− k.Down,
52− k.Up,
53− k.Forward,
54− k.Backward,
55− k.Close,
56− }
57−}
58−
59−// FullHelp implements help.KeyMap.
60−func (k KeyMap) FullHelp() [][]key.Binding {
61− m := [][]key.Binding{}
62− slice := k.KeyBindings()
63− for i := 0; i < len(slice); i += 4 {
64− end := min(i+4, len(slice))
65− m = append(m, slice[i:end])
66− }
67− return m
68−}
69−
70−// ShortHelp implements help.KeyMap.
71−func (k KeyMap) ShortHelp() []key.Binding {
72− return []key.Binding{
73− key.NewBinding(
74− key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
75− key.WithHelp("↑↓←→", "navigate"),
76− ),
77− k.Select,
78− k.Close,
79− }
80−}
internal/tui/components/dialogs/hyper/device_flow.godeleted−267
@@ -1,267 +0,0 @@
1−// Package hyper provides the dialog for Hyper device flow authentication.
2−package hyper
3−
4−import (
5− "context"
6− "fmt"
7− "time"
8−
9− "charm.land/bubbles/v2/spinner"
10− tea "charm.land/bubbletea/v2"
11− "charm.land/lipgloss/v2"
12− "github.com/charmbracelet/crush/internal/oauth"
13− "github.com/charmbracelet/crush/internal/oauth/hyper"
14− "github.com/charmbracelet/crush/internal/tui/styles"
15− "github.com/charmbracelet/crush/internal/tui/util"
16− "github.com/pkg/browser"
17−)
18−
19−// DeviceFlowState represents the current state of the device flow.
20−type DeviceFlowState int
21−
22−const (
23− DeviceFlowStateDisplay DeviceFlowState = iota
24− DeviceFlowStateSuccess
25− DeviceFlowStateError
26−)
27−
28−// DeviceAuthInitiatedMsg is sent when the device auth is initiated
29−// successfully.
30−type DeviceAuthInitiatedMsg struct {
31− deviceCode string
32− expiresIn int
33−}
34−
35−// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
36−type DeviceFlowCompletedMsg struct {
37− Token *oauth.Token
38−}
39−
40−// DeviceFlowErrorMsg is sent when the device flow encounters an error.
41−type DeviceFlowErrorMsg struct {
42− Error error
43−}
44−
45−// DeviceFlow handles the Hyper device flow authentication.
46−type DeviceFlow struct {
47− State DeviceFlowState
48− width int
49− deviceCode string
50− userCode string
51− verificationURL string
52− expiresIn int
53− token *oauth.Token
54− cancelFunc context.CancelFunc
55− spinner spinner.Model
56−}
57−
58−// NewDeviceFlow creates a new device flow component.
59−func NewDeviceFlow() *DeviceFlow {
60− s := spinner.New()
61− s.Spinner = spinner.Dot
62− s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
63− return &DeviceFlow{
64− State: DeviceFlowStateDisplay,
65− spinner: s,
66− }
67−}
68−
69−// Init initializes the device flow by calling the device auth API and starting polling.
70−func (d *DeviceFlow) Init() tea.Cmd {
71− return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
72−}
73−
74−// Update handles messages and state transitions.
75−func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
76− var cmd tea.Cmd
77− d.spinner, cmd = d.spinner.Update(msg)
78−
79− switch msg := msg.(type) {
80− case DeviceAuthInitiatedMsg:
81− // Start polling now that we have the device code.
82− d.expiresIn = msg.expiresIn
83− return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
84− case DeviceFlowCompletedMsg:
85− d.State = DeviceFlowStateSuccess
86− d.token = msg.Token
87− return d, nil
88− case DeviceFlowErrorMsg:
89− d.State = DeviceFlowStateError
90− return d, util.ReportError(msg.Error)
91− }
92−
93− return d, cmd
94−}
95−
96−// View renders the device flow dialog.
97−func (d *DeviceFlow) View() string {
98− t := styles.CurrentTheme()
99−
100− whiteStyle := lipgloss.NewStyle().Foreground(t.White)
101− primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
102− greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
103− linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
104− errorStyle := lipgloss.NewStyle().Foreground(t.Error)
105− mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
106−
107− switch d.State {
108− case DeviceFlowStateDisplay:
109− if d.userCode == "" {
110− return lipgloss.NewStyle().
111− Margin(0, 1).
112− Render(
113− greenStyle.Render(d.spinner.View()) +
114− mutedStyle.Render("Initializing..."),
115− )
116− }
117−
118− instructions := lipgloss.NewStyle().
119− Margin(1, 1, 0, 1).
120− Width(d.width - 2).
121− Render(
122− whiteStyle.Render("Press ") +
123− primaryStyle.Render("enter") +
124− whiteStyle.Render(" to copy the code below and open the browser."),
125− )
126−
127− codeBox := lipgloss.NewStyle().
128− Width(d.width-2).
129− Height(7).
130− Align(lipgloss.Center, lipgloss.Center).
131− Background(t.BgBaseLighter).
132− Margin(1).
133− Render(
134− lipgloss.NewStyle().
135− Bold(true).
136− Foreground(t.White).
137− Render(d.userCode),
138− )
139−
140− link := linkStyle.Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL)
141− url := mutedStyle.
142− Margin(0, 1).
143− Width(d.width - 2).
144− Render("Browser not opening? Refer to\n" + link)
145−
146− waiting := greenStyle.
147− Width(d.width-2).
148− Margin(1, 1, 0, 1).
149− Render(d.spinner.View() + "Verifying...")
150−
151− return lipgloss.JoinVertical(
152− lipgloss.Left,
153− instructions,
154− codeBox,
155− url,
156− waiting,
157− )
158−
159− case DeviceFlowStateSuccess:
160− return greenStyle.Margin(0, 1).Render("Authentication successful!")
161−
162− case DeviceFlowStateError:
163− return lipgloss.NewStyle().
164− Margin(0, 1).
165− Width(d.width - 2).
166− Render(errorStyle.Render("Authentication failed."))
167−
168− default:
169− return ""
170− }
171−}
172−
173−// SetWidth sets the width of the dialog.
174−func (d *DeviceFlow) SetWidth(w int) {
175− d.width = w
176−}
177−
178−// Cursor hides the cursor.
179−func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
180−
181−// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
182−func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
183− if d.State != DeviceFlowStateDisplay {
184− return nil
185− }
186− return tea.Sequence(
187− tea.SetClipboard(d.userCode),
188− func() tea.Msg {
189− if err := browser.OpenURL(d.verificationURL); err != nil {
190− return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
191− }
192− return nil
193− },
194− util.ReportInfo("Code copied and URL opened"),
195− )
196−}
197−
198−// CopyCode copies just the user code to the clipboard.
199−func (d *DeviceFlow) CopyCode() tea.Cmd {
200− if d.State != DeviceFlowStateDisplay {
201− return nil
202− }
203− return tea.Sequence(
204− tea.SetClipboard(d.userCode),
205− util.ReportInfo("Code copied to clipboard"),
206− )
207−}
208−
209−// Cancel cancels the device flow polling.
210−func (d *DeviceFlow) Cancel() {
211− if d.cancelFunc != nil {
212− d.cancelFunc()
213− }
214−}
215−
216−func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
217− ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
218− defer cancel()
219− authResp, err := hyper.InitiateDeviceAuth(ctx)
220− if err != nil {
221− return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
222− }
223−
224− d.deviceCode = authResp.DeviceCode
225− d.userCode = authResp.UserCode
226− d.verificationURL = authResp.VerificationURL
227−
228− return DeviceAuthInitiatedMsg{
229− deviceCode: authResp.DeviceCode,
230− expiresIn: authResp.ExpiresIn,
231− }
232−}
233−
234−// startPolling starts polling for the device token.
235−func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd {
236− return func() tea.Msg {
237− ctx, cancel := context.WithCancel(context.Background())
238− d.cancelFunc = cancel
239−
240− // Poll for refresh token.
241− refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn)
242− if err != nil {
243− if ctx.Err() != nil {
244− // Cancelled, don't report error.
245− return nil
246− }
247− return DeviceFlowErrorMsg{Error: err}
248− }
249−
250− // Exchange refresh token for access token.
251− token, err := hyper.ExchangeToken(ctx, refreshToken)
252− if err != nil {
253− return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)}
254− }
255−
256− // Verify the access token works.
257− introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
258− if err != nil {
259− return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)}
260− }
261− if !introspect.Active {
262− return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")}
263− }
264−
265− return DeviceFlowCompletedMsg{Token: token}
266− }
267−}
internal/tui/components/dialogs/keys.godeleted−43
@@ -1,43 +0,0 @@
1−package dialogs
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−// KeyMap defines keyboard bindings for dialog management.
8−type KeyMap struct {
9− Close key.Binding
10−}
11−
12−func DefaultKeyMap() KeyMap {
13− return KeyMap{
14− Close: key.NewBinding(
15− key.WithKeys("esc", "alt+esc"),
16− ),
17− }
18−}
19−
20−// KeyBindings implements layout.KeyMapProvider
21−func (k KeyMap) KeyBindings() []key.Binding {
22− return []key.Binding{
23− k.Close,
24− }
25−}
26−
27−// FullHelp implements help.KeyMap.
28−func (k KeyMap) FullHelp() [][]key.Binding {
29− m := [][]key.Binding{}
30− slice := k.KeyBindings()
31− for i := 0; i < len(slice); i += 4 {
32− end := min(i+4, len(slice))
33− m = append(m, slice[i:end])
34− }
35− return m
36−}
37−
38−// ShortHelp implements help.KeyMap.
39−func (k KeyMap) ShortHelp() []key.Binding {
40− return []key.Binding{
41− k.Close,
42− }
43−}
internal/tui/components/dialogs/models/apikey.godeleted−203
@@ -1,203 +0,0 @@
1−package models
2−
3−import (
4− "fmt"
5−
6− "charm.land/bubbles/v2/spinner"
7− "charm.land/bubbles/v2/textinput"
8− tea "charm.land/bubbletea/v2"
9− "charm.land/lipgloss/v2"
10− "github.com/charmbracelet/crush/internal/config"
11− "github.com/charmbracelet/crush/internal/home"
12− "github.com/charmbracelet/crush/internal/tui/styles"
13− "github.com/charmbracelet/crush/internal/tui/util"
14−)
15−
16−type APIKeyInputState int
17−
18−const (
19− APIKeyInputStateInitial APIKeyInputState = iota
20− APIKeyInputStateVerifying
21− APIKeyInputStateVerified
22− APIKeyInputStateError
23−)
24−
25−type APIKeyStateChangeMsg struct {
26− State APIKeyInputState
27−}
28−
29−type APIKeyInput struct {
30− input textinput.Model
31− width int
32− spinner spinner.Model
33− providerName string
34− state APIKeyInputState
35− title string
36− showTitle bool
37−}
38−
39−func NewAPIKeyInput() *APIKeyInput {
40− t := styles.CurrentTheme()
41−
42− ti := textinput.New()
43− ti.Placeholder = "Enter your API key..."
44− ti.SetVirtualCursor(false)
45− ti.Prompt = "> "
46− ti.SetStyles(t.S().TextInput)
47− ti.Focus()
48−
49− return &APIKeyInput{
50− input: ti,
51− state: APIKeyInputStateInitial,
52− spinner: spinner.New(
53− spinner.WithSpinner(spinner.Dot),
54− spinner.WithStyle(t.S().Base.Foreground(t.Green)),
55− ),
56− providerName: "Provider",
57− showTitle: true,
58− }
59−}
60−
61−func (a *APIKeyInput) SetProviderName(name string) {
62− a.providerName = name
63− a.updateStatePresentation()
64−}
65−
66−func (a *APIKeyInput) SetShowTitle(show bool) {
67− a.showTitle = show
68−}
69−
70−func (a *APIKeyInput) GetTitle() string {
71− return a.title
72−}
73−
74−func (a *APIKeyInput) Init() tea.Cmd {
75− a.updateStatePresentation()
76− return a.spinner.Tick
77−}
78−
79−func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) {
80− switch msg := msg.(type) {
81− case spinner.TickMsg:
82− if a.state == APIKeyInputStateVerifying {
83− var cmd tea.Cmd
84− a.spinner, cmd = a.spinner.Update(msg)
85− a.updateStatePresentation()
86− return a, cmd
87− }
88− return a, nil
89− case APIKeyStateChangeMsg:
90− a.state = msg.State
91− var cmd tea.Cmd
92− if msg.State == APIKeyInputStateVerifying {
93− cmd = a.spinner.Tick
94− }
95− a.updateStatePresentation()
96− return a, cmd
97− }
98−
99− var cmd tea.Cmd
100− a.input, cmd = a.input.Update(msg)
101− return a, cmd
102−}
103−
104−func (a *APIKeyInput) updateStatePresentation() {
105− t := styles.CurrentTheme()
106−
107− prefixStyle := t.S().Base.
108− Foreground(t.Primary)
109− accentStyle := t.S().Base.Foreground(t.Green).Bold(true)
110− errorStyle := t.S().Base.Foreground(t.Cherry)
111−
112− switch a.state {
113− case APIKeyInputStateInitial:
114− titlePrefix := prefixStyle.Render("Enter your ")
115− a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(".")
116− a.input.SetStyles(t.S().TextInput)
117− a.input.Prompt = "> "
118− case APIKeyInputStateVerifying:
119− titlePrefix := prefixStyle.Render("Verifying your ")
120− a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render("...")
121− ts := t.S().TextInput
122− // make the blurred state be the same
123− ts.Blurred.Prompt = ts.Focused.Prompt
124− a.input.Prompt = a.spinner.View()
125− a.input.Blur()
126− case APIKeyInputStateVerified:
127− a.title = accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(" validated.")
128− ts := t.S().TextInput
129− // make the blurred state be the same
130− ts.Blurred.Prompt = ts.Focused.Prompt
131− a.input.SetStyles(ts)
132− a.input.Prompt = styles.CheckIcon + " "
133− a.input.Blur()
134− case APIKeyInputStateError:
135− a.title = errorStyle.Render("Invalid ") + accentStyle.Render(a.providerName+" API Key") + errorStyle.Render(". Try again?")
136− ts := t.S().TextInput
137− ts.Focused.Prompt = ts.Focused.Prompt.Foreground(t.Cherry)
138− a.input.Focus()
139− a.input.SetStyles(ts)
140− a.input.Prompt = styles.ErrorIcon + " "
141− }
142−}
143−
144−func (a *APIKeyInput) View() string {
145− inputView := a.input.View()
146−
147− dataPath := config.GlobalConfigData()
148− dataPath = home.Short(dataPath)
149− helpText := styles.CurrentTheme().S().Muted.
150− Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
151−
152− var content string
153− if a.showTitle && a.title != "" {
154− content = lipgloss.JoinVertical(
155− lipgloss.Left,
156− a.title,
157− "",
158− inputView,
159− "",
160− helpText,
161− )
162− } else {
163− content = lipgloss.JoinVertical(
164− lipgloss.Left,
165− inputView,
166− "",
167− helpText,
168− )
169− }
170−
171− return content
172−}
173−
174−func (a *APIKeyInput) Cursor() *tea.Cursor {
175− cursor := a.input.Cursor()
176− if cursor != nil && a.showTitle {
177− cursor.Y += 2 // Adjust for title and spacing
178− }
179− return cursor
180−}
181−
182−func (a *APIKeyInput) Value() string {
183− return a.input.Value()
184−}
185−
186−func (a *APIKeyInput) Tick() tea.Cmd {
187− if a.state == APIKeyInputStateVerifying {
188− return a.spinner.Tick
189− }
190− return nil
191−}
192−
193−func (a *APIKeyInput) SetWidth(width int) {
194− a.width = width
195− a.input.SetWidth(width - 4)
196−}
197−
198−func (a *APIKeyInput) Reset() {
199− a.state = APIKeyInputStateInitial
200− a.input.SetValue("")
201− a.input.Focus()
202− a.updateStatePresentation()
203−}
internal/tui/components/dialogs/models/keys.godeleted−120
@@ -1,120 +0,0 @@
1−package models
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Select,
9− Next,
10− Previous,
11− Choose,
12− Tab,
13− Close key.Binding
14−
15− isAPIKeyHelp bool
16− isAPIKeyValid bool
17−
18− isHyperDeviceFlow bool
19− isCopilotDeviceFlow bool
20− isCopilotUnavailable bool
21−}
22−
23−func DefaultKeyMap() KeyMap {
24− return KeyMap{
25− Select: key.NewBinding(
26− key.WithKeys("enter", "ctrl+y"),
27− key.WithHelp("enter", "choose"),
28− ),
29− Next: key.NewBinding(
30− key.WithKeys("down", "ctrl+n"),
31− key.WithHelp("↓", "next item"),
32− ),
33− Previous: key.NewBinding(
34− key.WithKeys("up", "ctrl+p"),
35− key.WithHelp("↑", "previous item"),
36− ),
37− Choose: key.NewBinding(
38− key.WithKeys("left", "right", "h", "l"),
39− key.WithHelp("←→", "choose"),
40− ),
41− Tab: key.NewBinding(
42− key.WithKeys("tab"),
43− key.WithHelp("tab", "toggle type"),
44− ),
45− Close: key.NewBinding(
46− key.WithKeys("esc", "alt+esc"),
47− key.WithHelp("esc", "exit"),
48− ),
49− }
50−}
51−
52−// KeyBindings implements layout.KeyMapProvider
53−func (k KeyMap) KeyBindings() []key.Binding {
54− return []key.Binding{
55− k.Select,
56− k.Next,
57− k.Previous,
58− k.Tab,
59− k.Close,
60− }
61−}
62−
63−// FullHelp implements help.KeyMap.
64−func (k KeyMap) FullHelp() [][]key.Binding {
65− m := [][]key.Binding{}
66− slice := k.KeyBindings()
67− for i := 0; i < len(slice); i += 4 {
68− end := min(i+4, len(slice))
69− m = append(m, slice[i:end])
70− }
71− return m
72−}
73−
74−// ShortHelp implements help.KeyMap.
75−func (k KeyMap) ShortHelp() []key.Binding {
76− if k.isHyperDeviceFlow || k.isCopilotDeviceFlow {
77− return []key.Binding{
78− key.NewBinding(
79− key.WithKeys("c"),
80− key.WithHelp("c", "copy code"),
81− ),
82− key.NewBinding(
83− key.WithKeys("enter"),
84− key.WithHelp("enter", "copy & open"),
85− ),
86− k.Close,
87− }
88− }
89− if k.isCopilotUnavailable {
90− return []key.Binding{
91− key.NewBinding(
92− key.WithKeys("enter"),
93− key.WithHelp("enter", "open signup"),
94− ),
95− k.Close,
96− }
97− }
98− if k.isAPIKeyHelp && !k.isAPIKeyValid {
99− return []key.Binding{
100− key.NewBinding(
101− key.WithKeys("enter"),
102− key.WithHelp("enter", "submit"),
103− ),
104− k.Close,
105− }
106− } else if k.isAPIKeyValid {
107− return []key.Binding{
108− k.Select,
109− }
110− }
111− return []key.Binding{
112− key.NewBinding(
113− key.WithKeys("down", "up"),
114− key.WithHelp("↑↓", "choose"),
115− ),
116− k.Tab,
117− k.Select,
118− k.Close,
119− }
120−}
internal/tui/components/dialogs/models/list.godeleted−333
@@ -1,333 +0,0 @@
1−package models
2−
3−import (
4− "cmp"
5− "fmt"
6− "slices"
7− "strings"
8−
9− tea "charm.land/bubbletea/v2"
10− "charm.land/catwalk/pkg/catwalk"
11− "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/tui/exp/list"
13− "github.com/charmbracelet/crush/internal/tui/styles"
14− "github.com/charmbracelet/crush/internal/tui/util"
15−)
16−
17−type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
18−
19−type ModelListComponent struct {
20− list listModel
21− modelType int
22− providers []catwalk.Provider
23−}
24−
25−func modelKey(providerID, modelID string) string {
26− if providerID == "" || modelID == "" {
27− return ""
28− }
29− return providerID + ":" + modelID
30−}
31−
32−func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
33− t := styles.CurrentTheme()
34− inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
35− options := []list.ListOption{
36− list.WithKeyMap(keyMap),
37− list.WithWrapNavigation(),
38− }
39− if shouldResize {
40− options = append(options, list.WithResizeByList())
41− }
42− modelList := list.NewFilterableGroupedList(
43− []list.Group[list.CompletionItem[ModelOption]]{},
44− list.WithFilterInputStyle(inputStyle),
45− list.WithFilterPlaceholder(inputPlaceholder),
46− list.WithFilterListOptions(
47− options...,
48− ),
49− )
50−
51− return &ModelListComponent{
52− list: modelList,
53− modelType: LargeModelType,
54− }
55−}
56−
57−func (m *ModelListComponent) Init() tea.Cmd {
58− var cmds []tea.Cmd
59− if len(m.providers) == 0 {
60− cfg := config.Get()
61− providers, err := config.Providers(cfg)
62− filteredProviders := []catwalk.Provider{}
63− for _, p := range providers {
64− hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
65− isHyper := p.ID == "hyper"
66− isCopilot := p.ID == catwalk.InferenceProviderCopilot
67− if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot {
68− filteredProviders = append(filteredProviders, p)
69− }
70− }
71−
72− m.providers = filteredProviders
73− if err != nil {
74− cmds = append(cmds, util.ReportError(err))
75− }
76− }
77− cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType))
78− return tea.Batch(cmds...)
79−}
80−
81−func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
82− u, cmd := m.list.Update(msg)
83− m.list = u.(listModel)
84− return m, cmd
85−}
86−
87−func (m *ModelListComponent) View() string {
88− return m.list.View()
89−}
90−
91−func (m *ModelListComponent) Cursor() *tea.Cursor {
92− return m.list.Cursor()
93−}
94−
95−func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
96− return m.list.SetSize(width, height)
97−}
98−
99−func (m *ModelListComponent) SelectedModel() *ModelOption {
100− s := m.list.SelectedItem()
101− if s == nil {
102− return nil
103− }
104− sv := *s
105− model := sv.Value()
106− return &model
107−}
108−
109−func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
110− t := styles.CurrentTheme()
111− m.modelType = modelType
112−
113− var groups []list.Group[list.CompletionItem[ModelOption]]
114− // first none section
115− selectedItemID := ""
116− itemsByKey := make(map[string]list.CompletionItem[ModelOption])
117−
118− cfg := config.Get()
119− var currentModel config.SelectedModel
120− selectedType := config.SelectedModelTypeLarge
121− if m.modelType == LargeModelType {
122− currentModel = cfg.Models[config.SelectedModelTypeLarge]
123− selectedType = config.SelectedModelTypeLarge
124− } else {
125− currentModel = cfg.Models[config.SelectedModelTypeSmall]
126− selectedType = config.SelectedModelTypeSmall
127− }
128− recentItems := cfg.RecentModels[selectedType]
129−
130− configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon)
131− configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured"))
132−
133− // Create a map to track which providers we've already added
134− addedProviders := make(map[string]bool)
135−
136− // First, add any configured providers that are not in the known providers list
137− // These should appear at the top of the list
138− knownProviders, err := config.Providers(cfg)
139− if err != nil {
140− return util.ReportError(err)
141− }
142− for providerID, providerConfig := range cfg.Providers.Seq2() {
143− if providerConfig.Disable {
144− continue
145− }
146−
147− // Check if this provider is not in the known providers list
148− if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) ||
149− !slices.ContainsFunc(m.providers, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) {
150− // Convert config provider to provider.Provider format
151− configProvider := providerConfig.ToProvider()
152−
153− // Add this unknown provider to the list
154− name := configProvider.Name
155− if name == "" {
156− name = string(configProvider.ID)
157− }
158− section := list.NewItemSection(name)
159− section.SetInfo(configured)
160− group := list.Group[list.CompletionItem[ModelOption]]{
161− Section: section,
162− }
163− for _, model := range configProvider.Models {
164− modelOption := ModelOption{
165− Provider: configProvider,
166− Model: model,
167− }
168− key := modelKey(string(configProvider.ID), model.ID)
169− item := list.NewCompletionItem(
170− model.Name,
171− modelOption,
172− list.WithCompletionID(key),
173− )
174− itemsByKey[key] = item
175−
176− group.Items = append(group.Items, item)
177− if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
178− selectedItemID = item.ID()
179− }
180− }
181− groups = append(groups, group)
182−
183− addedProviders[providerID] = true
184− }
185− }
186−
187− // Move "Charm Hyper" to first position
188− // (but still after recent models and custom providers).
189− slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int {
190− switch {
191− case a.ID == "hyper":
192− return -1
193− case b.ID == "hyper":
194− return 1
195− default:
196− return 0
197− }
198− })
199−
200− // Then add the known providers from the predefined list
201− for _, provider := range m.providers {
202− // Skip if we already added this provider as an unknown provider
203− if addedProviders[string(provider.ID)] {
204− continue
205− }
206−
207− providerConfig, providerConfigured := cfg.Providers.Get(string(provider.ID))
208− if providerConfigured && providerConfig.Disable {
209− continue
210− }
211−
212− displayProvider := provider
213− if providerConfigured {
214− displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
215− modelIndex := make(map[string]int, len(displayProvider.Models))
216− for i, model := range displayProvider.Models {
217− modelIndex[model.ID] = i
218− }
219− for _, model := range providerConfig.Models {
220− if model.ID == "" {
221− continue
222− }
223− if idx, ok := modelIndex[model.ID]; ok {
224− if model.Name != "" {
225− displayProvider.Models[idx].Name = model.Name
226− }
227− continue
228− }
229− if model.Name == "" {
230− model.Name = model.ID
231− }
232− displayProvider.Models = append(displayProvider.Models, model)
233− modelIndex[model.ID] = len(displayProvider.Models) - 1
234− }
235− }
236−
237− name := displayProvider.Name
238− if name == "" {
239− name = string(displayProvider.ID)
240− }
241−
242− section := list.NewItemSection(name)
243− if providerConfigured {
244− section.SetInfo(configured)
245− }
246− group := list.Group[list.CompletionItem[ModelOption]]{
247− Section: section,
248− }
249− for _, model := range displayProvider.Models {
250− modelOption := ModelOption{
251− Provider: displayProvider,
252− Model: model,
253− }
254− key := modelKey(string(displayProvider.ID), model.ID)
255− item := list.NewCompletionItem(
256− model.Name,
257− modelOption,
258− list.WithCompletionID(key),
259− )
260− itemsByKey[key] = item
261− group.Items = append(group.Items, item)
262− if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider {
263− selectedItemID = item.ID()
264− }
265− }
266− groups = append(groups, group)
267− }
268−
269− if len(recentItems) > 0 {
270− recentSection := list.NewItemSection("Recently used")
271− recentGroup := list.Group[list.CompletionItem[ModelOption]]{
272− Section: recentSection,
273− }
274− var validRecentItems []config.SelectedModel
275− for _, recent := range recentItems {
276− key := modelKey(recent.Provider, recent.Model)
277− option, ok := itemsByKey[key]
278− if !ok {
279− continue
280− }
281− validRecentItems = append(validRecentItems, recent)
282− recentID := fmt.Sprintf("recent::%s", key)
283− modelOption := option.Value()
284− providerName := modelOption.Provider.Name
285− if providerName == "" {
286− providerName = string(modelOption.Provider.ID)
287− }
288− item := list.NewCompletionItem(
289− modelOption.Model.Name,
290− option.Value(),
291− list.WithCompletionID(recentID),
292− list.WithCompletionShortcut(providerName),
293− )
294− recentGroup.Items = append(recentGroup.Items, item)
295− if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
296− selectedItemID = recentID
297− }
298− }
299−
300− if len(validRecentItems) != len(recentItems) {
301− if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
302− return util.ReportError(err)
303− }
304− }
305−
306− if len(recentGroup.Items) > 0 {
307− groups = append([]list.Group[list.CompletionItem[ModelOption]]{recentGroup}, groups...)
308− }
309− }
310−
311− var cmds []tea.Cmd
312−
313− cmd := m.list.SetGroups(groups)
314−
315− if cmd != nil {
316− cmds = append(cmds, cmd)
317− }
318− cmd = m.list.SetSelected(selectedItemID)
319− if cmd != nil {
320− cmds = append(cmds, cmd)
321− }
322−
323− return tea.Sequence(cmds...)
324−}
325−
326−// GetModelType returns the current model type
327−func (m *ModelListComponent) GetModelType() int {
328− return m.modelType
329−}
330−
331−func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
332− m.list.SetInputPlaceholder(placeholder)
333−}
internal/tui/components/dialogs/models/list_recent_test.godeleted−369
@@ -1,369 +0,0 @@
1−package models
2−
3−import (
4− "encoding/json"
5− "io/fs"
6− "os"
7− "path/filepath"
8− "strings"
9− "testing"
10−
11− tea "charm.land/bubbletea/v2"
12− "charm.land/catwalk/pkg/catwalk"
13− "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/log"
15− "github.com/charmbracelet/crush/internal/tui/exp/list"
16− "github.com/stretchr/testify/require"
17−)
18−
19−// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop.
20−func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) {
21− t.Helper()
22− for cmd != nil {
23− msg := cmd()
24− var next tea.Cmd
25− _, next = m.Update(msg)
26− cmd = next
27− }
28−}
29−
30−// readConfigJSON reads and unmarshals the JSON config file at path.
31−func readConfigJSON(t *testing.T, path string) map[string]any {
32− t.Helper()
33− baseDir := filepath.Dir(path)
34− fileName := filepath.Base(path)
35− b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
36− require.NoError(t, err)
37− var out map[string]any
38− require.NoError(t, json.Unmarshal(b, &out))
39− return out
40−}
41−
42−// readRecentModels reads the recent_models section from the config file.
43−func readRecentModels(t *testing.T, path string) map[string]any {
44− t.Helper()
45− out := readConfigJSON(t, path)
46− rm, ok := out["recent_models"].(map[string]any)
47− require.True(t, ok)
48− return rm
49−}
50−
51−func TestModelList_RecentlyUsedSectionAndPrunesInvalid(t *testing.T) {
52− // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
53− log.Setup(os.DevNull, false)
54−
55− // Isolate config/data paths
56− cfgDir := t.TempDir()
57− dataDir := t.TempDir()
58− t.Setenv("XDG_CONFIG_HOME", cfgDir)
59− t.Setenv("XDG_DATA_HOME", dataDir)
60−
61− // Pre-seed config so provider auto-update is disabled and we have recents
62− confPath := filepath.Join(cfgDir, "crush", "crush.json")
63− require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
64− initial := map[string]any{
65− "options": map[string]any{
66− "disable_provider_auto_update": true,
67− },
68− "models": map[string]any{
69− "large": map[string]any{
70− "model": "m1",
71− "provider": "p1",
72− },
73− },
74− "recent_models": map[string]any{
75− "large": []any{
76− map[string]any{"model": "m2", "provider": "p1"}, // valid
77− map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned
78− },
79− },
80− }
81− bts, err := json.Marshal(initial)
82− require.NoError(t, err)
83− require.NoError(t, os.WriteFile(confPath, bts, 0o644))
84−
85− // Also create empty providers.json to prevent loading real providers
86− dataConfDir := filepath.Join(dataDir, "crush")
87− require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
88− emptyProviders := []byte("[]")
89− require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
90−
91− // Initialize global config instance (no network due to auto-update disabled)
92− _, err = config.Init(cfgDir, dataDir, false)
93− require.NoError(t, err)
94−
95− // Build a small provider set for the list component
96− provider := catwalk.Provider{
97− ID: catwalk.InferenceProvider("p1"),
98− Name: "Provider One",
99− Models: []catwalk.Model{
100− {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
101− {ID: "m2", Name: "Model Two", DefaultMaxTokens: 100}, // recent
102− },
103− }
104−
105− // Create and initialize the component with our provider set
106− listKeyMap := list.DefaultKeyMap()
107− cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
108− cmp.providers = []catwalk.Provider{provider}
109− execCmdML(t, cmp, cmp.Init())
110−
111− // Find all recent items (IDs prefixed with "recent::") and verify pruning
112− groups := cmp.list.Groups()
113− require.NotEmpty(t, groups)
114− var recentItems []list.CompletionItem[ModelOption]
115− for _, g := range groups {
116− for _, it := range g.Items {
117− if strings.HasPrefix(it.ID(), "recent::") {
118− recentItems = append(recentItems, it)
119− }
120− }
121− }
122− require.NotEmpty(t, recentItems, "no recent items found")
123− // Ensure the valid recent (p1:m2) is present and the invalid one is not
124− foundValid := false
125− for _, it := range recentItems {
126− if it.ID() == "recent::p1:m2" {
127− foundValid = true
128− }
129− require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned")
130− }
131− require.True(t, foundValid, "expected valid recent not found")
132−
133− // Verify original config in cfgDir remains unchanged
134− origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
135− afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
136− require.NoError(t, err)
137− var origParsed map[string]any
138− require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
139− origRM := origParsed["recent_models"].(map[string]any)
140− origLarge := origRM["large"].([]any)
141− require.Len(t, origLarge, 2, "original config should be unchanged")
142−
143− // Config should be rewritten with pruned recents in dataDir
144− dataConf := filepath.Join(dataDir, "crush", "crush.json")
145− rm := readRecentModels(t, dataConf)
146− largeAny, ok := rm["large"].([]any)
147− require.True(t, ok)
148− // Ensure that only valid recent(s) remain and the invalid one is removed
149− found := false
150− for _, v := range largeAny {
151− m := v.(map[string]any)
152− require.NotEqual(t, "unknown-provider", m["provider"], "invalid provider should be pruned")
153− if m["provider"] == "p1" && m["model"] == "m2" {
154− found = true
155− }
156− }
157− require.True(t, found, "persisted recents should include p1:m2")
158−}
159−
160−func TestModelList_PrunesInvalidModelWithinValidProvider(t *testing.T) {
161− // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
162− log.Setup(os.DevNull, false)
163−
164− // Isolate config/data paths
165− cfgDir := t.TempDir()
166− dataDir := t.TempDir()
167− t.Setenv("XDG_CONFIG_HOME", cfgDir)
168− t.Setenv("XDG_DATA_HOME", dataDir)
169−
170− // Pre-seed config with valid provider but one invalid model
171− confPath := filepath.Join(cfgDir, "crush", "crush.json")
172− require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
173− initial := map[string]any{
174− "options": map[string]any{
175− "disable_provider_auto_update": true,
176− },
177− "models": map[string]any{
178− "large": map[string]any{
179− "model": "m1",
180− "provider": "p1",
181− },
182− },
183− "recent_models": map[string]any{
184− "large": []any{
185− map[string]any{"model": "m1", "provider": "p1"}, // valid
186− map[string]any{"model": "missing", "provider": "p1"}, // invalid model
187− },
188− },
189− }
190− bts, err := json.Marshal(initial)
191− require.NoError(t, err)
192− require.NoError(t, os.WriteFile(confPath, bts, 0o644))
193−
194− // Create empty providers.json
195− dataConfDir := filepath.Join(dataDir, "crush")
196− require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
197− emptyProviders := []byte("[]")
198− require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
199−
200− // Initialize global config instance
201− _, err = config.Init(cfgDir, dataDir, false)
202− require.NoError(t, err)
203−
204− // Build provider set that only includes m1, not "missing"
205− provider := catwalk.Provider{
206− ID: catwalk.InferenceProvider("p1"),
207− Name: "Provider One",
208− Models: []catwalk.Model{
209− {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
210− },
211− }
212−
213− // Create and initialize component
214− listKeyMap := list.DefaultKeyMap()
215− cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
216− cmp.providers = []catwalk.Provider{provider}
217− execCmdML(t, cmp, cmp.Init())
218−
219− // Find all recent items
220− groups := cmp.list.Groups()
221− require.NotEmpty(t, groups)
222− var recentItems []list.CompletionItem[ModelOption]
223− for _, g := range groups {
224− for _, it := range g.Items {
225− if strings.HasPrefix(it.ID(), "recent::") {
226− recentItems = append(recentItems, it)
227− }
228− }
229− }
230− require.NotEmpty(t, recentItems, "valid recent should exist")
231−
232− // Verify the valid recent is present and invalid model is not
233− foundValid := false
234− for _, it := range recentItems {
235− if it.ID() == "recent::p1:m1" {
236− foundValid = true
237− }
238− require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned")
239− }
240− require.True(t, foundValid, "valid recent p1:m1 should be present")
241−
242− // Verify original config in cfgDir remains unchanged
243− origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
244− afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
245− require.NoError(t, err)
246− var origParsed map[string]any
247− require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
248− origRM := origParsed["recent_models"].(map[string]any)
249− origLarge := origRM["large"].([]any)
250− require.Len(t, origLarge, 2, "original config should be unchanged")
251−
252− // Config should be rewritten with pruned recents in dataDir
253− dataConf := filepath.Join(dataDir, "crush", "crush.json")
254− rm := readRecentModels(t, dataConf)
255− largeAny, ok := rm["large"].([]any)
256− require.True(t, ok)
257− require.Len(t, largeAny, 1, "should only have one valid model")
258− // Verify only p1:m1 remains
259− m := largeAny[0].(map[string]any)
260− require.Equal(t, "p1", m["provider"])
261− require.Equal(t, "m1", m["model"])
262−}
263−
264−func TestModelKey_EmptyInputs(t *testing.T) {
265− // Empty provider
266− require.Equal(t, "", modelKey("", "model"))
267− // Empty model
268− require.Equal(t, "", modelKey("provider", ""))
269− // Both empty
270− require.Equal(t, "", modelKey("", ""))
271− // Valid inputs
272− require.Equal(t, "p:m", modelKey("p", "m"))
273−}
274−
275−func TestModelList_AllRecentsInvalid(t *testing.T) {
276− // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
277− log.Setup(os.DevNull, false)
278−
279− // Isolate config/data paths
280− cfgDir := t.TempDir()
281− dataDir := t.TempDir()
282− t.Setenv("XDG_CONFIG_HOME", cfgDir)
283− t.Setenv("XDG_DATA_HOME", dataDir)
284−
285− // Pre-seed config with only invalid recents
286− confPath := filepath.Join(cfgDir, "crush", "crush.json")
287− require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
288− initial := map[string]any{
289− "options": map[string]any{
290− "disable_provider_auto_update": true,
291− },
292− "models": map[string]any{
293− "large": map[string]any{
294− "model": "m1",
295− "provider": "p1",
296− },
297− },
298− "recent_models": map[string]any{
299− "large": []any{
300− map[string]any{"model": "x", "provider": "unknown1"},
301− map[string]any{"model": "y", "provider": "unknown2"},
302− },
303− },
304− }
305− bts, err := json.Marshal(initial)
306− require.NoError(t, err)
307− require.NoError(t, os.WriteFile(confPath, bts, 0o644))
308−
309− // Also create empty providers.json and data config
310− dataConfDir := filepath.Join(dataDir, "crush")
311− require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
312− emptyProviders := []byte("[]")
313− require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
314−
315− // Initialize global config instance with isolated dataDir
316− _, err = config.Init(cfgDir, dataDir, false)
317− require.NoError(t, err)
318−
319− // Build provider set (doesn't include unknown1 or unknown2)
320− provider := catwalk.Provider{
321− ID: catwalk.InferenceProvider("p1"),
322− Name: "Provider One",
323− Models: []catwalk.Model{
324− {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
325− },
326− }
327−
328− // Create and initialize component
329− listKeyMap := list.DefaultKeyMap()
330− cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
331− cmp.providers = []catwalk.Provider{provider}
332− execCmdML(t, cmp, cmp.Init())
333−
334− // Verify no recent items exist in UI
335− groups := cmp.list.Groups()
336− require.NotEmpty(t, groups)
337− var recentItems []list.CompletionItem[ModelOption]
338− for _, g := range groups {
339− for _, it := range g.Items {
340− if strings.HasPrefix(it.ID(), "recent::") {
341− recentItems = append(recentItems, it)
342− }
343− }
344− }
345− require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section")
346−
347− // Verify original config in cfgDir remains unchanged
348− origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
349− afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
350− require.NoError(t, err)
351− var origParsed map[string]any
352− require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
353− origRM := origParsed["recent_models"].(map[string]any)
354− origLarge := origRM["large"].([]any)
355− require.Len(t, origLarge, 2, "original config should be unchanged")
356−
357− // Config should be rewritten with empty recents in dataDir
358− dataConf := filepath.Join(dataDir, "crush", "crush.json")
359− rm := readRecentModels(t, dataConf)
360− // When all recents are pruned, the value may be nil or an empty array
361− largeVal := rm["large"]
362− if largeVal == nil {
363− // nil is acceptable - means empty
364− return
365− }
366− largeAny, ok := largeVal.([]any)
367− require.True(t, ok, "large key should be nil or array")
368− require.Empty(t, largeAny, "persisted recents should be empty after pruning all invalid entries")
369−}
internal/tui/components/dialogs/models/models.godeleted−549
@@ -1,549 +0,0 @@
1−// Package models provides the model selection dialog for the TUI.
2−package models
3−
4−import (
5− "fmt"
6− "time"
7−
8− "charm.land/bubbles/v2/help"
9− "charm.land/bubbles/v2/key"
10− "charm.land/bubbles/v2/spinner"
11− tea "charm.land/bubbletea/v2"
12− "charm.land/catwalk/pkg/catwalk"
13− "charm.land/lipgloss/v2"
14− hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
15− "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/tui/components/core"
17− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18− "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
19− "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
20− "github.com/charmbracelet/crush/internal/tui/exp/list"
21− "github.com/charmbracelet/crush/internal/tui/styles"
22− "github.com/charmbracelet/crush/internal/tui/util"
23−)
24−
25−const (
26− ModelsDialogID dialogs.DialogID = "models"
27−
28− defaultWidth = 60
29−)
30−
31−const (
32− LargeModelType int = iota
33− SmallModelType
34−
35− largeModelInputPlaceholder = "Choose a model for large, complex tasks"
36− smallModelInputPlaceholder = "Choose a model for small, simple tasks"
37−)
38−
39−// ModelSelectedMsg is sent when a model is selected
40−type ModelSelectedMsg struct {
41− Model config.SelectedModel
42− ModelType config.SelectedModelType
43−}
44−
45−// CloseModelDialogMsg is sent when a model is selected
46−type CloseModelDialogMsg struct{}
47−
48−// ModelDialog interface for the model selection dialog
49−type ModelDialog interface {
50− dialogs.DialogModel
51−}
52−
53−type ModelOption struct {
54− Provider catwalk.Provider
55− Model catwalk.Model
56−}
57−
58−type modelDialogCmp struct {
59− width int
60− wWidth int
61− wHeight int
62−
63− modelList *ModelListComponent
64− keyMap KeyMap
65− help help.Model
66−
67− // API key state
68− needsAPIKey bool
69− apiKeyInput *APIKeyInput
70− selectedModel *ModelOption
71− selectedModelType config.SelectedModelType
72− isAPIKeyValid bool
73− apiKeyValue string
74−
75− // Hyper device flow state
76− hyperDeviceFlow *hyper.DeviceFlow
77− showHyperDeviceFlow bool
78−
79− // Copilot device flow state
80− copilotDeviceFlow *copilot.DeviceFlow
81− showCopilotDeviceFlow bool
82−}
83−
84−func NewModelDialogCmp() ModelDialog {
85− keyMap := DefaultKeyMap()
86−
87− listKeyMap := list.DefaultKeyMap()
88− listKeyMap.Down.SetEnabled(false)
89− listKeyMap.Up.SetEnabled(false)
90− listKeyMap.DownOneItem = keyMap.Next
91− listKeyMap.UpOneItem = keyMap.Previous
92−
93− t := styles.CurrentTheme()
94− modelList := NewModelListComponent(listKeyMap, largeModelInputPlaceholder, true)
95− apiKeyInput := NewAPIKeyInput()
96− apiKeyInput.SetShowTitle(false)
97− help := help.New()
98− help.Styles = t.S().Help
99−
100− return &modelDialogCmp{
101− modelList: modelList,
102− apiKeyInput: apiKeyInput,
103− width: defaultWidth,
104− keyMap: DefaultKeyMap(),
105− help: help,
106− }
107−}
108−
109−func (m *modelDialogCmp) Init() tea.Cmd {
110− return tea.Batch(
111− m.modelList.Init(),
112− m.apiKeyInput.Init(),
113− )
114−}
115−
116−func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
117− switch msg := msg.(type) {
118− case tea.WindowSizeMsg:
119− m.wWidth = msg.Width
120− m.wHeight = msg.Height
121− m.apiKeyInput.SetWidth(m.width - 2)
122− m.help.SetWidth(m.width - 2)
123− return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
124− case APIKeyStateChangeMsg:
125− u, cmd := m.apiKeyInput.Update(msg)
126− m.apiKeyInput = u.(*APIKeyInput)
127− return m, cmd
128− case hyper.DeviceFlowCompletedMsg:
129− return m, m.saveOauthTokenAndContinue(msg.Token, true)
130− case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
131− if m.hyperDeviceFlow != nil {
132− u, cmd := m.hyperDeviceFlow.Update(msg)
133− m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
134− return m, cmd
135− }
136− return m, nil
137− case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
138− if m.copilotDeviceFlow != nil {
139− u, cmd := m.copilotDeviceFlow.Update(msg)
140− m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
141− return m, cmd
142− }
143− return m, nil
144− case copilot.DeviceFlowCompletedMsg:
145− return m, m.saveOauthTokenAndContinue(msg.Token, true)
146− case tea.KeyPressMsg:
147− switch {
148− // Handle Hyper device flow keys
149− case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow:
150− return m, m.hyperDeviceFlow.CopyCode()
151− case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
152− return m, m.copilotDeviceFlow.CopyCode()
153− case key.Matches(msg, m.keyMap.Select):
154− // If showing device flow, enter copies code and opens URL
155− if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
156− return m, m.hyperDeviceFlow.CopyCodeAndOpenURL()
157− }
158− if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
159− return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
160− }
161− selectedItem := m.modelList.SelectedModel()
162− if selectedItem == nil {
163− return m, nil
164− }
165−
166− modelType := config.SelectedModelTypeLarge
167− if m.modelList.GetModelType() == SmallModelType {
168− modelType = config.SelectedModelTypeSmall
169− }
170−
171− askForApiKey := func() {
172− m.keyMap.isAPIKeyHelp = true
173− m.showHyperDeviceFlow = false
174− m.showCopilotDeviceFlow = false
175− m.needsAPIKey = true
176− m.selectedModel = selectedItem
177− m.selectedModelType = modelType
178− m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
179− }
180−
181− if m.isAPIKeyValid {
182− return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
183− }
184− if m.needsAPIKey {
185− // Handle API key submission
186− m.apiKeyValue = m.apiKeyInput.Value()
187− provider, err := m.getProvider(m.selectedModel.Provider.ID)
188− if err != nil || provider == nil {
189− return m, util.ReportError(fmt.Errorf("provider %s not found", m.selectedModel.Provider.ID))
190− }
191− providerConfig := config.ProviderConfig{
192− ID: string(m.selectedModel.Provider.ID),
193− Name: m.selectedModel.Provider.Name,
194− APIKey: m.apiKeyValue,
195− Type: provider.Type,
196− BaseURL: provider.APIEndpoint,
197− }
198− return m, tea.Sequence(
199− util.CmdHandler(APIKeyStateChangeMsg{
200− State: APIKeyInputStateVerifying,
201− }),
202− func() tea.Msg {
203− start := time.Now()
204− err := providerConfig.TestConnection(config.Get().Resolver())
205− // intentionally wait for at least 750ms to make sure the user sees the spinner
206− elapsed := time.Since(start)
207− if elapsed < 750*time.Millisecond {
208− time.Sleep(750*time.Millisecond - elapsed)
209− }
210− if err == nil {
211− m.isAPIKeyValid = true
212− return APIKeyStateChangeMsg{
213− State: APIKeyInputStateVerified,
214− }
215− }
216− return APIKeyStateChangeMsg{
217− State: APIKeyInputStateError,
218− }
219− },
220− )
221− }
222−
223− // Check if provider is configured
224− if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
225− return m, tea.Sequence(
226− util.CmdHandler(dialogs.CloseDialogMsg{}),
227− util.CmdHandler(ModelSelectedMsg{
228− Model: config.SelectedModel{
229− Model: selectedItem.Model.ID,
230− Provider: string(selectedItem.Provider.ID),
231− ReasoningEffort: selectedItem.Model.DefaultReasoningEffort,
232− MaxTokens: selectedItem.Model.DefaultMaxTokens,
233− },
234− ModelType: modelType,
235− }),
236− )
237− }
238− switch selectedItem.Provider.ID {
239− case hyperp.Name:
240− m.showHyperDeviceFlow = true
241− m.selectedModel = selectedItem
242− m.selectedModelType = modelType
243− m.hyperDeviceFlow = hyper.NewDeviceFlow()
244− m.hyperDeviceFlow.SetWidth(m.width - 2)
245− return m, m.hyperDeviceFlow.Init()
246− case catwalk.InferenceProviderCopilot:
247− if token, ok := config.Get().ImportCopilot(); ok {
248− m.selectedModel = selectedItem
249− m.selectedModelType = modelType
250− return m, m.saveOauthTokenAndContinue(token, true)
251− }
252− m.showCopilotDeviceFlow = true
253− m.selectedModel = selectedItem
254− m.selectedModelType = modelType
255− m.copilotDeviceFlow = copilot.NewDeviceFlow()
256− m.copilotDeviceFlow.SetWidth(m.width - 2)
257− return m, m.copilotDeviceFlow.Init()
258− }
259− // For other providers, show API key input
260− askForApiKey()
261− return m, nil
262− case key.Matches(msg, m.keyMap.Tab):
263− switch {
264− case m.needsAPIKey:
265− u, cmd := m.apiKeyInput.Update(msg)
266− m.apiKeyInput = u.(*APIKeyInput)
267− return m, cmd
268− case m.modelList.GetModelType() == LargeModelType:
269− m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
270− return m, m.modelList.SetModelType(SmallModelType)
271− default:
272− m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
273− return m, m.modelList.SetModelType(LargeModelType)
274− }
275− case key.Matches(msg, m.keyMap.Close):
276− switch {
277− case m.showHyperDeviceFlow:
278− if m.hyperDeviceFlow != nil {
279− m.hyperDeviceFlow.Cancel()
280− }
281− m.showHyperDeviceFlow = false
282− m.selectedModel = nil
283− case m.showCopilotDeviceFlow:
284− if m.copilotDeviceFlow != nil {
285− m.copilotDeviceFlow.Cancel()
286− }
287− m.showCopilotDeviceFlow = false
288− m.selectedModel = nil
289− case m.needsAPIKey:
290− if m.isAPIKeyValid {
291− return m, nil
292− }
293− // Go back to model selection
294− m.needsAPIKey = false
295− m.selectedModel = nil
296− m.isAPIKeyValid = false
297− m.apiKeyValue = ""
298− m.apiKeyInput.Reset()
299− return m, nil
300− default:
301− return m, util.CmdHandler(dialogs.CloseDialogMsg{})
302− }
303− default:
304− switch {
305− case m.needsAPIKey:
306− u, cmd := m.apiKeyInput.Update(msg)
307− m.apiKeyInput = u.(*APIKeyInput)
308− return m, cmd
309− default:
310− u, cmd := m.modelList.Update(msg)
311− m.modelList = u
312− return m, cmd
313− }
314− }
315− case tea.PasteMsg:
316− switch {
317− case m.needsAPIKey:
318− u, cmd := m.apiKeyInput.Update(msg)
319− m.apiKeyInput = u.(*APIKeyInput)
320− return m, cmd
321− default:
322− var cmd tea.Cmd
323− m.modelList, cmd = m.modelList.Update(msg)
324− return m, cmd
325− }
326− case spinner.TickMsg:
327− u, cmd := m.apiKeyInput.Update(msg)
328− m.apiKeyInput = u.(*APIKeyInput)
329− if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
330− u, cmd = m.hyperDeviceFlow.Update(msg)
331− m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
332− }
333− if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
334− u, cmd = m.copilotDeviceFlow.Update(msg)
335− m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
336− }
337− return m, cmd
338− default:
339− // Pass all other messages to the device flow for spinner animation
340− switch {
341− case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil:
342− u, cmd := m.hyperDeviceFlow.Update(msg)
343− m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
344− return m, cmd
345− case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil:
346− u, cmd := m.copilotDeviceFlow.Update(msg)
347− m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
348− return m, cmd
349− default:
350− u, cmd := m.apiKeyInput.Update(msg)
351− m.apiKeyInput = u.(*APIKeyInput)
352− return m, cmd
353− }
354− }
355− return m, nil
356−}
357−
358−func (m *modelDialogCmp) View() string {
359− t := styles.CurrentTheme()
360−
361− if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
362− // Show Hyper device flow
363− m.keyMap.isHyperDeviceFlow = true
364− deviceFlowView := m.hyperDeviceFlow.View()
365− content := lipgloss.JoinVertical(
366− lipgloss.Left,
367− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with Hyper", m.width-4)),
368− deviceFlowView,
369− "",
370− t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
371− )
372− return m.style().Render(content)
373− }
374− if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
375− // Show Hyper device flow
376− m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable
377− m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable
378− deviceFlowView := m.copilotDeviceFlow.View()
379− content := lipgloss.JoinVertical(
380− lipgloss.Left,
381− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)),
382− deviceFlowView,
383− "",
384− t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
385− )
386− return m.style().Render(content)
387− }
388−
389− // Reset the flags when not showing device flow
390− m.keyMap.isHyperDeviceFlow = false
391− m.keyMap.isCopilotDeviceFlow = false
392− m.keyMap.isCopilotUnavailable = false
393−
394− switch {
395− case m.needsAPIKey:
396− // Show API key input
397− m.keyMap.isAPIKeyHelp = true
398− m.keyMap.isAPIKeyValid = m.isAPIKeyValid
399− apiKeyView := m.apiKeyInput.View()
400− apiKeyView = t.S().Base.Width(m.width - 3).Height(lipgloss.Height(apiKeyView)).PaddingLeft(1).Render(apiKeyView)
401− content := lipgloss.JoinVertical(
402− lipgloss.Left,
403− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(m.apiKeyInput.GetTitle(), m.width-4)),
404− apiKeyView,
405− "",
406− t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
407− )
408− return m.style().Render(content)
409− }
410−
411− // Show model selection
412− listView := m.modelList.View()
413− radio := m.modelTypeRadio()
414− content := lipgloss.JoinVertical(
415− lipgloss.Left,
416− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-lipgloss.Width(radio)-5)+" "+radio),
417− listView,
418− "",
419− t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
420− )
421− return m.style().Render(content)
422−}
423−
424−func (m *modelDialogCmp) Cursor() *tea.Cursor {
425− if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
426− return m.hyperDeviceFlow.Cursor()
427− }
428− if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
429− return m.copilotDeviceFlow.Cursor()
430− }
431− if m.needsAPIKey {
432− cursor := m.apiKeyInput.Cursor()
433− if cursor != nil {
434− cursor = m.moveCursor(cursor)
435− return cursor
436− }
437− } else {
438− cursor := m.modelList.Cursor()
439− if cursor != nil {
440− cursor = m.moveCursor(cursor)
441− return cursor
442− }
443− }
444− return nil
445−}
446−
447−func (m *modelDialogCmp) style() lipgloss.Style {
448− t := styles.CurrentTheme()
449− return t.S().Base.
450− Width(m.width).
451− Border(lipgloss.RoundedBorder()).
452− BorderForeground(t.BorderFocus)
453−}
454−
455−func (m *modelDialogCmp) listWidth() int {
456− return m.width - 2
457−}
458−
459−func (m *modelDialogCmp) listHeight() int {
460− return m.wHeight / 2
461−}
462−
463−func (m *modelDialogCmp) Position() (int, int) {
464− row := m.wHeight/4 - 2 // just a bit above the center
465− col := m.wWidth / 2
466− col -= m.width / 2
467− return row, col
468−}
469−
470−func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
471− row, col := m.Position()
472− if m.needsAPIKey {
473− offset := row + 3 // Border + title + API key input offset
474− cursor.Y += offset
475− cursor.X = cursor.X + col + 2
476− } else {
477− offset := row + 3 // Border + title
478− cursor.Y += offset
479− cursor.X = cursor.X + col + 2
480− }
481− return cursor
482−}
483−
484−func (m *modelDialogCmp) ID() dialogs.DialogID {
485− return ModelsDialogID
486−}
487−
488−func (m *modelDialogCmp) modelTypeRadio() string {
489− t := styles.CurrentTheme()
490− choices := []string{"Large Task", "Small Task"}
491− iconSelected := "◉"
492− iconUnselected := "○"
493− if m.modelList.GetModelType() == LargeModelType {
494− return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
495− }
496− return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
497−}
498−
499−func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
500− cfg := config.Get()
501− _, ok := cfg.Providers.Get(providerID)
502− return ok
503−}
504−
505−func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
506− cfg := config.Get()
507− providers, err := config.Providers(cfg)
508− if err != nil {
509− return nil, err
510− }
511− for _, p := range providers {
512− if p.ID == providerID {
513− return &p, nil
514− }
515− }
516− return nil, nil
517−}
518−
519−func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd {
520− if m.selectedModel == nil {
521− return util.ReportError(fmt.Errorf("no model selected"))
522− }
523−
524− cfg := config.Get()
525− err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey)
526− if err != nil {
527− return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
528− }
529−
530− // Reset API key state and continue with model selection
531− selectedModel := *m.selectedModel
532− var cmds []tea.Cmd
533− if close {
534− cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
535− }
536− cmds = append(
537− cmds,
538− util.CmdHandler(ModelSelectedMsg{
539− Model: config.SelectedModel{
540− Model: selectedModel.Model.ID,
541− Provider: string(selectedModel.Provider.ID),
542− ReasoningEffort: selectedModel.Model.DefaultReasoningEffort,
543− MaxTokens: selectedModel.Model.DefaultMaxTokens,
544− },
545− ModelType: m.selectedModelType,
546− }),
547− )
548− return tea.Sequence(cmds...)
549−}
internal/tui/components/dialogs/permissions/keys.godeleted−113
@@ -1,113 +0,0 @@
1−package permissions
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Left,
9− Right,
10− Tab,
11− Select,
12− Allow,
13− AllowSession,
14− Deny,
15− ToggleDiffMode,
16− ScrollDown,
17− ScrollUp key.Binding
18− ScrollLeft,
19− ScrollRight key.Binding
20−}
21−
22−func DefaultKeyMap() KeyMap {
23− return KeyMap{
24− Left: key.NewBinding(
25− key.WithKeys("left", "h"),
26− key.WithHelp("←", "previous"),
27− ),
28− Right: key.NewBinding(
29− key.WithKeys("right", "l"),
30− key.WithHelp("→", "next"),
31− ),
32− Tab: key.NewBinding(
33− key.WithKeys("tab"),
34− key.WithHelp("tab", "switch"),
35− ),
36− Allow: key.NewBinding(
37− key.WithKeys("a", "A", "ctrl+a"),
38− key.WithHelp("a", "allow"),
39− ),
40− AllowSession: key.NewBinding(
41− key.WithKeys("s", "S", "ctrl+s"),
42− key.WithHelp("s", "allow session"),
43− ),
44− Deny: key.NewBinding(
45− key.WithKeys("d", "D", "esc"),
46− key.WithHelp("d", "deny"),
47− ),
48− Select: key.NewBinding(
49− key.WithKeys("enter", "ctrl+y"),
50− key.WithHelp("enter", "confirm"),
51− ),
52− ToggleDiffMode: key.NewBinding(
53− key.WithKeys("t"),
54− key.WithHelp("t", "toggle diff mode"),
55− ),
56− ScrollDown: key.NewBinding(
57− key.WithKeys("shift+down", "J"),
58− key.WithHelp("shift+↓", "scroll down"),
59− ),
60− ScrollUp: key.NewBinding(
61− key.WithKeys("shift+up", "K"),
62− key.WithHelp("shift+↑", "scroll up"),
63− ),
64− ScrollLeft: key.NewBinding(
65− key.WithKeys("shift+left", "H"),
66− key.WithHelp("shift+←", "scroll left"),
67− ),
68− ScrollRight: key.NewBinding(
69− key.WithKeys("shift+right", "L"),
70− key.WithHelp("shift+→", "scroll right"),
71− ),
72− }
73−}
74−
75−// KeyBindings implements layout.KeyMapProvider
76−func (k KeyMap) KeyBindings() []key.Binding {
77− return []key.Binding{
78− k.Left,
79− k.Right,
80− k.Tab,
81− k.Select,
82− k.Allow,
83− k.AllowSession,
84− k.Deny,
85− k.ToggleDiffMode,
86− k.ScrollDown,
87− k.ScrollUp,
88− k.ScrollLeft,
89− k.ScrollRight,
90− }
91−}
92−
93−// FullHelp implements help.KeyMap.
94−func (k KeyMap) FullHelp() [][]key.Binding {
95− m := [][]key.Binding{}
96− slice := k.KeyBindings()
97− for i := 0; i < len(slice); i += 4 {
98− end := min(i+4, len(slice))
99− m = append(m, slice[i:end])
100− }
101− return m
102−}
103−
104−// ShortHelp implements help.KeyMap.
105−func (k KeyMap) ShortHelp() []key.Binding {
106− return []key.Binding{
107− k.ToggleDiffMode,
108− key.NewBinding(
109− key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
110− key.WithHelp("shift+←↓↑→", "scroll"),
111− ),
112− }
113−}
internal/tui/components/dialogs/permissions/permissions.godeleted−899
@@ -1,899 +0,0 @@
1−package permissions
2−
3−import (
4− "encoding/json"
5− "fmt"
6− "strings"
7−
8− "charm.land/bubbles/v2/help"
9− "charm.land/bubbles/v2/key"
10− "charm.land/bubbles/v2/viewport"
11− tea "charm.land/bubbletea/v2"
12− "charm.land/lipgloss/v2"
13− "github.com/charmbracelet/crush/internal/agent/tools"
14− "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/permission"
16− "github.com/charmbracelet/crush/internal/tui/components/core"
17− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18− "github.com/charmbracelet/crush/internal/tui/styles"
19− "github.com/charmbracelet/crush/internal/tui/util"
20− "github.com/charmbracelet/x/ansi"
21−)
22−
23−type PermissionAction string
24−
25−// Permission responses
26−const (
27− PermissionAllow PermissionAction = "allow"
28− PermissionAllowForSession PermissionAction = "allow_session"
29− PermissionDeny PermissionAction = "deny"
30−
31− PermissionsDialogID dialogs.DialogID = "permissions"
32−)
33−
34−// PermissionResponseMsg represents the user's response to a permission request
35−type PermissionResponseMsg struct {
36− Permission permission.PermissionRequest
37− Action PermissionAction
38−}
39−
40−// PermissionDialogCmp interface for permission dialog component
41−type PermissionDialogCmp interface {
42− dialogs.DialogModel
43−}
44−
45−// permissionDialogCmp is the implementation of PermissionDialog
46−type permissionDialogCmp struct {
47− wWidth int
48− wHeight int
49− width int
50− height int
51− permission permission.PermissionRequest
52− contentViewPort viewport.Model
53− selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
54−
55− // Diff view state
56− defaultDiffSplitMode bool // true for split, false for unified
57− diffSplitMode *bool // nil means use defaultDiffSplitMode
58− diffXOffset int // horizontal scroll offset
59− diffYOffset int // vertical scroll offset
60−
61− // Caching
62− cachedContent string
63− contentDirty bool
64−
65− positionRow int // Row position for dialog
66− positionCol int // Column position for dialog
67−
68− finalDialogHeight int
69−
70− keyMap KeyMap
71−}
72−
73−func NewPermissionDialogCmp(permission permission.PermissionRequest, opts *Options) PermissionDialogCmp {
74− if opts == nil {
75− opts = &Options{}
76− }
77−
78− // Create viewport for content
79− contentViewport := viewport.New()
80− return &permissionDialogCmp{
81− contentViewPort: contentViewport,
82− selectedOption: 0, // Default to "Allow"
83− permission: permission,
84− diffSplitMode: opts.isSplitMode(),
85− keyMap: DefaultKeyMap(),
86− contentDirty: true, // Mark as dirty initially
87− }
88−}
89−
90−func (p *permissionDialogCmp) Init() tea.Cmd {
91− return p.contentViewPort.Init()
92−}
93−
94−func (p *permissionDialogCmp) supportsDiffView() bool {
95− return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
96−}
97−
98−func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
99− var cmds []tea.Cmd
100−
101− switch msg := msg.(type) {
102− case tea.WindowSizeMsg:
103− p.wWidth = msg.Width
104− p.wHeight = msg.Height
105− p.contentDirty = true // Mark content as dirty on window resize
106− cmd := p.SetSize()
107− cmds = append(cmds, cmd)
108− case tea.KeyPressMsg:
109− switch {
110− case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
111− p.selectedOption = (p.selectedOption + 1) % 3
112− return p, nil
113− case key.Matches(msg, p.keyMap.Left):
114− p.selectedOption = (p.selectedOption + 2) % 3
115− case key.Matches(msg, p.keyMap.Select):
116− return p, p.selectCurrentOption()
117− case key.Matches(msg, p.keyMap.Allow):
118− return p, tea.Batch(
119− util.CmdHandler(dialogs.CloseDialogMsg{}),
120− util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
121− )
122− case key.Matches(msg, p.keyMap.AllowSession):
123− return p, tea.Batch(
124− util.CmdHandler(dialogs.CloseDialogMsg{}),
125− util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
126− )
127− case key.Matches(msg, p.keyMap.Deny):
128− return p, tea.Batch(
129− util.CmdHandler(dialogs.CloseDialogMsg{}),
130− util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
131− )
132− case key.Matches(msg, p.keyMap.ToggleDiffMode):
133− if p.supportsDiffView() {
134− if p.diffSplitMode == nil {
135− diffSplitMode := !p.defaultDiffSplitMode
136− p.diffSplitMode = &diffSplitMode
137− } else {
138− *p.diffSplitMode = !*p.diffSplitMode
139− }
140− p.contentDirty = true // Mark content as dirty when diff mode changes
141− return p, nil
142− }
143− case key.Matches(msg, p.keyMap.ScrollDown):
144− if p.supportsDiffView() {
145− p.scrollDown()
146− return p, nil
147− }
148− case key.Matches(msg, p.keyMap.ScrollUp):
149− if p.supportsDiffView() {
150− p.scrollUp()
151− return p, nil
152− }
153− case key.Matches(msg, p.keyMap.ScrollLeft):
154− if p.supportsDiffView() {
155− p.scrollLeft()
156− return p, nil
157− }
158− case key.Matches(msg, p.keyMap.ScrollRight):
159− if p.supportsDiffView() {
160− p.scrollRight()
161− return p, nil
162− }
163− default:
164− // Pass other keys to viewport
165− viewPort, cmd := p.contentViewPort.Update(msg)
166− p.contentViewPort = viewPort
167− cmds = append(cmds, cmd)
168− }
169− case tea.MouseWheelMsg:
170− if p.supportsDiffView() && p.isMouseOverDialog(msg.Mouse().X, msg.Mouse().Y) {
171− switch msg.Button {
172− case tea.MouseWheelDown:
173− p.scrollDown()
174− case tea.MouseWheelUp:
175− p.scrollUp()
176− case tea.MouseWheelLeft:
177− p.scrollLeft()
178− case tea.MouseWheelRight:
179− p.scrollRight()
180− }
181− }
182− }
183−
184− return p, tea.Batch(cmds...)
185−}
186−
187−func (p *permissionDialogCmp) scrollDown() {
188− p.diffYOffset += 1
189− p.contentDirty = true
190−}
191−
192−func (p *permissionDialogCmp) scrollUp() {
193− p.diffYOffset = max(0, p.diffYOffset-1)
194− p.contentDirty = true
195−}
196−
197−func (p *permissionDialogCmp) scrollLeft() {
198− p.diffXOffset = max(0, p.diffXOffset-5)
199− p.contentDirty = true
200−}
201−
202−func (p *permissionDialogCmp) scrollRight() {
203− p.diffXOffset += 5
204− p.contentDirty = true
205−}
206−
207−// isMouseOverDialog checks if the given mouse coordinates are within the dialog bounds.
208−// Returns true if the mouse is over the dialog area, false otherwise.
209−func (p *permissionDialogCmp) isMouseOverDialog(x, y int) bool {
210− if p.permission.ID == "" {
211− return false
212− }
213− var (
214− dialogX = p.positionCol
215− dialogY = p.positionRow
216− dialogWidth = p.width
217− dialogHeight = p.finalDialogHeight
218− )
219− return x >= dialogX && x < dialogX+dialogWidth && y >= dialogY && y < dialogY+dialogHeight
220−}
221−
222−func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
223− var action PermissionAction
224−
225− switch p.selectedOption {
226− case 0:
227− action = PermissionAllow
228− case 1:
229− action = PermissionAllowForSession
230− case 2:
231− action = PermissionDeny
232− }
233−
234− return tea.Batch(
235− util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
236− util.CmdHandler(dialogs.CloseDialogMsg{}),
237− )
238−}
239−
240−func (p *permissionDialogCmp) renderButtons() string {
241− t := styles.CurrentTheme()
242− baseStyle := t.S().Base
243−
244− buttons := []core.ButtonOpts{
245− {
246− Text: "Allow",
247− UnderlineIndex: 0, // "A"
248− Selected: p.selectedOption == 0,
249− },
250− {
251− Text: "Allow for Session",
252− UnderlineIndex: 10, // "S" in "Session"
253− Selected: p.selectedOption == 1,
254− },
255− {
256− Text: "Deny",
257− UnderlineIndex: 0, // "D"
258− Selected: p.selectedOption == 2,
259− },
260− }
261−
262− content := core.SelectableButtons(buttons, " ")
263− if lipgloss.Width(content) > p.width-4 {
264− content = core.SelectableButtonsVertical(buttons, 1)
265− return baseStyle.AlignVertical(lipgloss.Center).
266− AlignHorizontal(lipgloss.Center).
267− Width(p.width - 4).
268− Render(content)
269− }
270−
271− return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
272−}
273−
274−func (p *permissionDialogCmp) renderHeader() string {
275− t := styles.CurrentTheme()
276− baseStyle := t.S().Base
277−
278− toolKey := t.S().Muted.Render("Tool")
279− toolValue := t.S().Text.
280− Width(p.width - lipgloss.Width(toolKey)).
281− Render(fmt.Sprintf(" %s", p.permission.ToolName))
282−
283− pathKey := t.S().Muted.Render("Path")
284− pathValue := t.S().Text.
285− Width(p.width - lipgloss.Width(pathKey)).
286− Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path)))
287−
288− headerParts := []string{
289− lipgloss.JoinHorizontal(
290− lipgloss.Left,
291− toolKey,
292− toolValue,
293− ),
294− lipgloss.JoinHorizontal(
295− lipgloss.Left,
296− pathKey,
297− pathValue,
298− ),
299− }
300−
301− // Add tool-specific header information
302− switch p.permission.ToolName {
303− case tools.BashToolName:
304− params := p.permission.Params.(tools.BashPermissionsParams)
305− descKey := t.S().Muted.Render("Desc")
306− descValue := t.S().Text.
307− Width(p.width - lipgloss.Width(descKey)).
308− Render(fmt.Sprintf(" %s", params.Description))
309− headerParts = append(headerParts,
310− lipgloss.JoinHorizontal(
311− lipgloss.Left,
312− descKey,
313− descValue,
314− ),
315− baseStyle.Render(strings.Repeat(" ", p.width)),
316− t.S().Muted.Width(p.width).Render("Command"),
317− )
318− case tools.DownloadToolName:
319− params := p.permission.Params.(tools.DownloadPermissionsParams)
320− urlKey := t.S().Muted.Render("URL")
321− urlValue := t.S().Text.
322− Width(p.width - lipgloss.Width(urlKey)).
323− Render(fmt.Sprintf(" %s", params.URL))
324− fileKey := t.S().Muted.Render("File")
325− filePath := t.S().Text.
326− Width(p.width - lipgloss.Width(fileKey)).
327− Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
328− headerParts = append(headerParts,
329− lipgloss.JoinHorizontal(
330− lipgloss.Left,
331− urlKey,
332− urlValue,
333− ),
334− lipgloss.JoinHorizontal(
335− lipgloss.Left,
336− fileKey,
337− filePath,
338− ),
339− baseStyle.Render(strings.Repeat(" ", p.width)),
340− )
341− case tools.EditToolName:
342− params := p.permission.Params.(tools.EditPermissionsParams)
343− fileKey := t.S().Muted.Render("File")
344− filePath := t.S().Text.
345− Width(p.width - lipgloss.Width(fileKey)).
346− Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
347− headerParts = append(headerParts,
348− lipgloss.JoinHorizontal(
349− lipgloss.Left,
350− fileKey,
351− filePath,
352− ),
353− baseStyle.Render(strings.Repeat(" ", p.width)),
354− )
355−
356− case tools.WriteToolName:
357− params := p.permission.Params.(tools.WritePermissionsParams)
358− fileKey := t.S().Muted.Render("File")
359− filePath := t.S().Text.
360− Width(p.width - lipgloss.Width(fileKey)).
361− Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
362− headerParts = append(headerParts,
363− lipgloss.JoinHorizontal(
364− lipgloss.Left,
365− fileKey,
366− filePath,
367− ),
368− baseStyle.Render(strings.Repeat(" ", p.width)),
369− )
370− case tools.MultiEditToolName:
371− params := p.permission.Params.(tools.MultiEditPermissionsParams)
372− fileKey := t.S().Muted.Render("File")
373− filePath := t.S().Text.
374− Width(p.width - lipgloss.Width(fileKey)).
375− Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
376− headerParts = append(headerParts,
377− lipgloss.JoinHorizontal(
378− lipgloss.Left,
379− fileKey,
380− filePath,
381− ),
382− baseStyle.Render(strings.Repeat(" ", p.width)),
383− )
384− case tools.FetchToolName:
385− headerParts = append(headerParts,
386− baseStyle.Render(strings.Repeat(" ", p.width)),
387− t.S().Muted.Width(p.width).Bold(true).Render("URL"),
388− )
389− case tools.AgenticFetchToolName:
390− headerParts = append(headerParts,
391− baseStyle.Render(strings.Repeat(" ", p.width)),
392− t.S().Muted.Width(p.width).Bold(true).Render("Web"),
393− )
394− case tools.ViewToolName:
395− params := p.permission.Params.(tools.ViewPermissionsParams)
396− fileKey := t.S().Muted.Render("File")
397− filePath := t.S().Text.
398− Width(p.width - lipgloss.Width(fileKey)).
399− Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
400− headerParts = append(headerParts,
401− lipgloss.JoinHorizontal(
402− lipgloss.Left,
403− fileKey,
404− filePath,
405− ),
406− baseStyle.Render(strings.Repeat(" ", p.width)),
407− )
408− case tools.LSToolName:
409− params := p.permission.Params.(tools.LSPermissionsParams)
410− pathKey := t.S().Muted.Render("Directory")
411− pathValue := t.S().Text.
412− Width(p.width - lipgloss.Width(pathKey)).
413− Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.Path)))
414− headerParts = append(headerParts,
415− lipgloss.JoinHorizontal(
416− lipgloss.Left,
417− pathKey,
418− pathValue,
419− ),
420− baseStyle.Render(strings.Repeat(" ", p.width)),
421− )
422− }
423−
424− return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
425−}
426−
427−func (p *permissionDialogCmp) getOrGenerateContent() string {
428− // Return cached content if available and not dirty
429− if !p.contentDirty && p.cachedContent != "" {
430− return p.cachedContent
431− }
432−
433− // Generate new content
434− var content string
435− switch p.permission.ToolName {
436− case tools.BashToolName:
437− content = p.generateBashContent()
438− case tools.DownloadToolName:
439− content = p.generateDownloadContent()
440− case tools.EditToolName:
441− content = p.generateEditContent()
442− case tools.WriteToolName:
443− content = p.generateWriteContent()
444− case tools.MultiEditToolName:
445− content = p.generateMultiEditContent()
446− case tools.FetchToolName:
447− content = p.generateFetchContent()
448− case tools.AgenticFetchToolName:
449− content = p.generateAgenticFetchContent()
450− case tools.ViewToolName:
451− content = p.generateViewContent()
452− case tools.LSToolName:
453− content = p.generateLSContent()
454− default:
455− content = p.generateDefaultContent()
456− }
457−
458− // Cache the result
459− p.cachedContent = content
460− p.contentDirty = false
461−
462− return content
463−}
464−
465−func (p *permissionDialogCmp) generateBashContent() string {
466− t := styles.CurrentTheme()
467− baseStyle := t.S().Base.Background(t.BgSubtle)
468− if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
469− content := pr.Command
470− t := styles.CurrentTheme()
471− content = strings.TrimSpace(content)
472− lines := strings.Split(content, "\n")
473−
474− width := p.width - 4
475− var out []string
476− for _, ln := range lines {
477− out = append(out, t.S().Muted.
478− Width(width).
479− Padding(0, 3).
480− Foreground(t.FgBase).
481− Background(t.BgSubtle).
482− Render(ln))
483− }
484−
485− // Ensure minimum of 7 lines for command display
486− minLines := 7
487− for len(out) < minLines {
488− out = append(out, t.S().Muted.
489− Width(width).
490− Padding(0, 3).
491− Foreground(t.FgBase).
492− Background(t.BgSubtle).
493− Render(""))
494− }
495−
496− // Use the cache for markdown rendering
497− renderedContent := strings.Join(out, "\n")
498− finalContent := baseStyle.
499− Width(p.contentViewPort.Width()).
500− Padding(1, 0).
501− Render(renderedContent)
502−
503− return finalContent
504− }
505− return ""
506−}
507−
508−func (p *permissionDialogCmp) generateEditContent() string {
509− if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
510− formatter := core.DiffFormatter().
511− Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
512− After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
513− Height(p.contentViewPort.Height()).
514− Width(p.contentViewPort.Width()).
515− XOffset(p.diffXOffset).
516− YOffset(p.diffYOffset)
517− if p.useDiffSplitMode() {
518− formatter = formatter.Split()
519− } else {
520− formatter = formatter.Unified()
521− }
522−
523− diff := formatter.String()
524− return diff
525− }
526− return ""
527−}
528−
529−func (p *permissionDialogCmp) generateWriteContent() string {
530− if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
531− // Use the cache for diff rendering
532− formatter := core.DiffFormatter().
533− Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
534− After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
535− Height(p.contentViewPort.Height()).
536− Width(p.contentViewPort.Width()).
537− XOffset(p.diffXOffset).
538− YOffset(p.diffYOffset)
539− if p.useDiffSplitMode() {
540− formatter = formatter.Split()
541− } else {
542− formatter = formatter.Unified()
543− }
544−
545− diff := formatter.String()
546− return diff
547− }
548− return ""
549−}
550−
551−func (p *permissionDialogCmp) generateDownloadContent() string {
552− t := styles.CurrentTheme()
553− baseStyle := t.S().Base.Background(t.BgSubtle)
554− if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
555− content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath))
556− if pr.Timeout > 0 {
557− content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout)
558− }
559−
560− finalContent := baseStyle.
561− Padding(1, 2).
562− Width(p.contentViewPort.Width()).
563− Render(content)
564− return finalContent
565− }
566− return ""
567−}
568−
569−func (p *permissionDialogCmp) generateMultiEditContent() string {
570− if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok {
571− // Use the cache for diff rendering
572− formatter := core.DiffFormatter().
573− Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
574− After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
575− Height(p.contentViewPort.Height()).
576− Width(p.contentViewPort.Width()).
577− XOffset(p.diffXOffset).
578− YOffset(p.diffYOffset)
579− if p.useDiffSplitMode() {
580− formatter = formatter.Split()
581− } else {
582− formatter = formatter.Unified()
583− }
584−
585− diff := formatter.String()
586− return diff
587− }
588− return ""
589−}
590−
591−func (p *permissionDialogCmp) generateFetchContent() string {
592− t := styles.CurrentTheme()
593− baseStyle := t.S().Base.Background(t.BgSubtle)
594− if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
595− finalContent := baseStyle.
596− Padding(1, 2).
597− Width(p.contentViewPort.Width()).
598− Render(pr.URL)
599− return finalContent
600− }
601− return ""
602−}
603−
604−func (p *permissionDialogCmp) generateAgenticFetchContent() string {
605− t := styles.CurrentTheme()
606− baseStyle := t.S().Base.Background(t.BgSubtle)
607− if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok {
608− var content string
609− if pr.URL != "" {
610− content = fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
611− } else {
612− content = fmt.Sprintf("Prompt: %s", pr.Prompt)
613− }
614− finalContent := baseStyle.
615− Padding(1, 2).
616− Width(p.contentViewPort.Width()).
617− Render(content)
618− return finalContent
619− }
620− return ""
621−}
622−
623−func (p *permissionDialogCmp) generateViewContent() string {
624− t := styles.CurrentTheme()
625− baseStyle := t.S().Base.Background(t.BgSubtle)
626− if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok {
627− content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath))
628− if pr.Offset > 0 {
629− content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1)
630− }
631− if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit
632− content += fmt.Sprintf("\nLines to read: %d", pr.Limit)
633− }
634−
635− finalContent := baseStyle.
636− Padding(1, 2).
637− Width(p.contentViewPort.Width()).
638− Render(content)
639− return finalContent
640− }
641− return ""
642−}
643−
644−func (p *permissionDialogCmp) generateLSContent() string {
645− t := styles.CurrentTheme()
646− baseStyle := t.S().Base.Background(t.BgSubtle)
647− if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
648− content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path))
649− if len(pr.Ignore) > 0 {
650− content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", "))
651− }
652−
653− finalContent := baseStyle.
654− Padding(1, 2).
655− Width(p.contentViewPort.Width()).
656− Render(content)
657− return finalContent
658− }
659− return ""
660−}
661−
662−func (p *permissionDialogCmp) generateDefaultContent() string {
663− t := styles.CurrentTheme()
664− baseStyle := t.S().Base.Background(t.BgSubtle)
665−
666− content := p.permission.Description
667−
668− // Add pretty-printed JSON parameters for MCP tools
669− if p.permission.Params != nil {
670− var paramStr string
671−
672− // Ensure params is a string
673− if str, ok := p.permission.Params.(string); ok {
674− paramStr = str
675− } else {
676− paramStr = fmt.Sprintf("%v", p.permission.Params)
677− }
678−
679− // Try to parse as JSON for pretty printing
680− var parsed any
681− if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
682− if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
683− if content != "" {
684− content += "\n\n"
685− }
686− content += string(b)
687− }
688− } else {
689− // Not JSON, show as-is
690− if content != "" {
691− content += "\n\n"
692− }
693− content += paramStr
694− }
695− }
696−
697− content = strings.TrimSpace(content)
698− content = "\n" + content + "\n"
699− lines := strings.Split(content, "\n")
700−
701− width := p.width - 4
702− var out []string
703− for _, ln := range lines {
704− ln = " " + ln // left padding
705− if len(ln) > width {
706− ln = ansi.Truncate(ln, width, "…")
707− }
708− out = append(out, t.S().Muted.
709− Width(width).
710− Foreground(t.FgBase).
711− Background(t.BgSubtle).
712− Render(ln))
713− }
714−
715− // Use the cache for markdown rendering
716− renderedContent := strings.Join(out, "\n")
717− finalContent := baseStyle.
718− Width(p.contentViewPort.Width()).
719− Render(renderedContent)
720−
721− if renderedContent == "" {
722− return ""
723− }
724−
725− return finalContent
726−}
727−
728−func (p *permissionDialogCmp) useDiffSplitMode() bool {
729− if p.diffSplitMode != nil {
730− return *p.diffSplitMode
731− }
732− return p.defaultDiffSplitMode
733−}
734−
735−func (p *permissionDialogCmp) styleViewport() string {
736− t := styles.CurrentTheme()
737− return t.S().Base.Render(p.contentViewPort.View())
738−}
739−
740−func (p *permissionDialogCmp) render() string {
741− t := styles.CurrentTheme()
742− baseStyle := t.S().Base
743− title := core.Title("Permission Required", p.width-4)
744− // Render header
745− headerContent := p.renderHeader()
746− // Render buttons
747− buttons := p.renderButtons()
748−
749− p.contentViewPort.SetWidth(p.width - 4)
750−
751− // Always set viewport content (the caching is handled in getOrGenerateContent)
752− const minContentHeight = 9
753−
754− availableDialogHeight := max(minContentHeight, p.height-minContentHeight)
755− p.contentViewPort.SetHeight(availableDialogHeight)
756− contentFinal := p.getOrGenerateContent()
757− contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal))
758−
759− p.contentViewPort.SetHeight(contentHeight)
760− p.contentViewPort.SetContent(contentFinal)
761−
762− p.positionRow = p.wHeight / 2
763− p.positionRow -= (contentHeight + 9) / 2
764− p.positionRow -= 3 // Move dialog slightly higher than middle
765−
766− var contentHelp string
767− if p.supportsDiffView() {
768− contentHelp = help.New().View(p.keyMap)
769− }
770−
771− // Calculate content height dynamically based on window size
772− strs := []string{
773− title,
774− "",
775− headerContent,
776− "",
777− p.styleViewport(),
778− "",
779− buttons,
780− "",
781− }
782− if contentHelp != "" {
783− strs = append(strs, "", contentHelp)
784− }
785− content := lipgloss.JoinVertical(lipgloss.Top, strs...)
786−
787− dialog := baseStyle.
788− Padding(0, 1).
789− Border(lipgloss.RoundedBorder()).
790− BorderForeground(t.BorderFocus).
791− Width(p.width).
792− Render(
793− content,
794− )
795− p.finalDialogHeight = lipgloss.Height(dialog)
796− return dialog
797−}
798−
799−func (p *permissionDialogCmp) View() string {
800− return p.render()
801−}
802−
803−func (p *permissionDialogCmp) SetSize() tea.Cmd {
804− if p.permission.ID == "" {
805− return nil
806− }
807−
808− oldWidth, oldHeight := p.width, p.height
809−
810− switch p.permission.ToolName {
811− case tools.BashToolName:
812− p.width = int(float64(p.wWidth) * 0.8)
813− p.height = int(float64(p.wHeight) * 0.3)
814− case tools.DownloadToolName:
815− p.width = int(float64(p.wWidth) * 0.8)
816− p.height = int(float64(p.wHeight) * 0.4)
817− case tools.EditToolName:
818− p.width = int(float64(p.wWidth) * 0.8)
819− p.height = int(float64(p.wHeight) * 0.8)
820− case tools.WriteToolName:
821− p.width = int(float64(p.wWidth) * 0.8)
822− p.height = int(float64(p.wHeight) * 0.8)
823− case tools.MultiEditToolName:
824− p.width = int(float64(p.wWidth) * 0.8)
825− p.height = int(float64(p.wHeight) * 0.8)
826− case tools.FetchToolName:
827− p.width = int(float64(p.wWidth) * 0.8)
828− p.height = int(float64(p.wHeight) * 0.3)
829− case tools.AgenticFetchToolName:
830− p.width = int(float64(p.wWidth) * 0.8)
831− p.height = int(float64(p.wHeight) * 0.4)
832− case tools.ViewToolName:
833− p.width = int(float64(p.wWidth) * 0.8)
834− p.height = int(float64(p.wHeight) * 0.4)
835− case tools.LSToolName:
836− p.width = int(float64(p.wWidth) * 0.8)
837− p.height = int(float64(p.wHeight) * 0.4)
838− default:
839− p.width = int(float64(p.wWidth) * 0.7)
840− p.height = int(float64(p.wHeight) * 0.5)
841− }
842−
843− // Default to diff split mode when dialog is wide enough.
844− p.defaultDiffSplitMode = p.width >= 140
845−
846− // Set a maximum width for the dialog
847− p.width = min(p.width, 180)
848−
849− // Mark content as dirty if size changed
850− if oldWidth != p.width || oldHeight != p.height {
851− p.contentDirty = true
852− }
853− p.positionRow = p.wHeight / 2
854− p.positionRow -= p.height / 2
855− p.positionRow -= 3 // Move dialog slightly higher than middle
856− p.positionCol = p.wWidth / 2
857− p.positionCol -= p.width / 2
858− return nil
859−}
860−
861−func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
862− content, err := generator()
863− if err != nil {
864− return fmt.Sprintf("Error rendering markdown: %v", err)
865− }
866−
867− return content
868−}
869−
870−// ID implements PermissionDialogCmp.
871−func (p *permissionDialogCmp) ID() dialogs.DialogID {
872− return PermissionsDialogID
873−}
874−
875−// Position implements PermissionDialogCmp.
876−func (p *permissionDialogCmp) Position() (int, int) {
877− return p.positionRow, p.positionCol
878−}
879−
880−// Options for create a new permission dialog
881−type Options struct {
882− DiffMode string // split or unified, empty means use defaultDiffSplitMode
883−}
884−
885−// isSplitMode returns internal representation of diff mode switch
886−func (o Options) isSplitMode() *bool {
887− var split bool
888−
889− switch o.DiffMode {
890− case "split":
891− split = true
892− case "unified":
893− split = false
894− default:
895− return nil
896− }
897−
898− return &split
899−}
internal/tui/components/dialogs/quit/keys.godeleted−75
@@ -1,75 +0,0 @@
1−package quit
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−// KeyMap defines the keyboard bindings for the quit dialog.
8−type KeyMap struct {
9− LeftRight,
10− EnterSpace,
11− Yes,
12− No,
13− Tab,
14− Close key.Binding
15−}
16−
17−func DefaultKeymap() KeyMap {
18− return KeyMap{
19− LeftRight: key.NewBinding(
20− key.WithKeys("left", "right"),
21− key.WithHelp("←/→", "switch options"),
22− ),
23− EnterSpace: key.NewBinding(
24− key.WithKeys("enter", " "),
25− key.WithHelp("enter/space", "confirm"),
26− ),
27− Yes: key.NewBinding(
28− key.WithKeys("y", "Y", "ctrl+c"),
29− key.WithHelp("y/Y/ctrl+c", "yes"),
30− ),
31− No: key.NewBinding(
32− key.WithKeys("n", "N"),
33− key.WithHelp("n/N", "no"),
34− ),
35− Tab: key.NewBinding(
36− key.WithKeys("tab"),
37− key.WithHelp("tab", "switch options"),
38− ),
39− Close: key.NewBinding(
40− key.WithKeys("esc", "alt+esc"),
41− key.WithHelp("esc", "cancel"),
42− ),
43− }
44−}
45−
46−// KeyBindings implements layout.KeyMapProvider
47−func (k KeyMap) KeyBindings() []key.Binding {
48− return []key.Binding{
49− k.LeftRight,
50− k.EnterSpace,
51− k.Yes,
52− k.No,
53− k.Tab,
54− k.Close,
55− }
56−}
57−
58−// FullHelp implements help.KeyMap.
59−func (k KeyMap) FullHelp() [][]key.Binding {
60− m := [][]key.Binding{}
61− slice := k.KeyBindings()
62− for i := 0; i < len(slice); i += 4 {
63− end := min(i+4, len(slice))
64− m = append(m, slice[i:end])
65− }
66− return m
67−}
68−
69−// ShortHelp implements help.KeyMap.
70−func (k KeyMap) ShortHelp() []key.Binding {
71− return []key.Binding{
72− k.LeftRight,
73− k.EnterSpace,
74− }
75−}
internal/tui/components/dialogs/quit/quit.godeleted−120
@@ -1,120 +0,0 @@
1−package quit
2−
3−import (
4− "charm.land/bubbles/v2/key"
5− tea "charm.land/bubbletea/v2"
6− "charm.land/lipgloss/v2"
7− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
8− "github.com/charmbracelet/crush/internal/tui/styles"
9− "github.com/charmbracelet/crush/internal/tui/util"
10−)
11−
12−const (
13− question = "Are you sure you want to quit?"
14− QuitDialogID dialogs.DialogID = "quit"
15−)
16−
17−// QuitDialog represents a confirmation dialog for quitting the application.
18−type QuitDialog interface {
19− dialogs.DialogModel
20−}
21−
22−type quitDialogCmp struct {
23− wWidth int
24− wHeight int
25−
26− selectedNo bool // true if "No" button is selected
27− keymap KeyMap
28−}
29−
30−// NewQuitDialog creates a new quit confirmation dialog.
31−func NewQuitDialog() QuitDialog {
32− return &quitDialogCmp{
33− selectedNo: true, // Default to "No" for safety
34− keymap: DefaultKeymap(),
35− }
36−}
37−
38−func (q *quitDialogCmp) Init() tea.Cmd {
39− return nil
40−}
41−
42−// Update handles keyboard input for the quit dialog.
43−func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
44− switch msg := msg.(type) {
45− case tea.WindowSizeMsg:
46− q.wWidth = msg.Width
47− q.wHeight = msg.Height
48− case tea.KeyPressMsg:
49− switch {
50− case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab):
51− q.selectedNo = !q.selectedNo
52− return q, nil
53− case key.Matches(msg, q.keymap.EnterSpace):
54− if !q.selectedNo {
55− return q, tea.Quit
56− }
57− return q, util.CmdHandler(dialogs.CloseDialogMsg{})
58− case key.Matches(msg, q.keymap.Yes):
59− return q, tea.Quit
60− case key.Matches(msg, q.keymap.No, q.keymap.Close):
61− return q, util.CmdHandler(dialogs.CloseDialogMsg{})
62− }
63− }
64− return q, nil
65−}
66−
67−// View renders the quit dialog with Yes/No buttons.
68−func (q *quitDialogCmp) View() string {
69− t := styles.CurrentTheme()
70− baseStyle := t.S().Base
71− yesStyle := t.S().Text
72− noStyle := yesStyle
73−
74− if q.selectedNo {
75− noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
76− yesStyle = yesStyle.Background(t.BgSubtle)
77− } else {
78− yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
79− noStyle = noStyle.Background(t.BgSubtle)
80− }
81−
82− const horizontalPadding = 3
83− yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") +
84− yesStyle.PaddingRight(horizontalPadding).Render("ep!")
85− noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") +
86− noStyle.PaddingRight(horizontalPadding).Render("ope")
87−
88− buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
89− lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton),
90− )
91−
92− content := baseStyle.Render(
93− lipgloss.JoinVertical(
94− lipgloss.Center,
95− question,
96− "",
97− buttons,
98− ),
99− )
100−
101− quitDialogStyle := baseStyle.
102− Padding(1, 2).
103− Border(lipgloss.RoundedBorder()).
104− BorderForeground(t.BorderFocus)
105−
106− return quitDialogStyle.Render(content)
107−}
108−
109−func (q *quitDialogCmp) Position() (int, int) {
110− row := q.wHeight / 2
111− row -= 7 / 2
112− col := q.wWidth / 2
113− col -= (lipgloss.Width(question) + 4) / 2
114−
115− return row, col
116−}
117−
118−func (q *quitDialogCmp) ID() dialogs.DialogID {
119− return QuitDialogID
120−}
internal/tui/components/dialogs/reasoning/reasoning.godeleted−264
@@ -1,264 +0,0 @@
1−package reasoning
2−
3−import (
4− "charm.land/bubbles/v2/help"
5− "charm.land/bubbles/v2/key"
6− tea "charm.land/bubbletea/v2"
7− "charm.land/lipgloss/v2"
8− "golang.org/x/text/cases"
9− "golang.org/x/text/language"
10−
11− "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/tui/components/core"
13− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
14− "github.com/charmbracelet/crush/internal/tui/exp/list"
15− "github.com/charmbracelet/crush/internal/tui/styles"
16− "github.com/charmbracelet/crush/internal/tui/util"
17−)
18−
19−const (
20− ReasoningDialogID dialogs.DialogID = "reasoning"
21−
22− defaultWidth int = 50
23−)
24−
25−type listModel = list.FilterableList[list.CompletionItem[EffortOption]]
26−
27−type EffortOption struct {
28− Title string
29− Effort string
30−}
31−
32−type ReasoningDialog interface {
33− dialogs.DialogModel
34−}
35−
36−type reasoningDialogCmp struct {
37− width int
38− wWidth int // Width of the terminal window
39− wHeight int // Height of the terminal window
40−
41− effortList listModel
42− keyMap ReasoningDialogKeyMap
43− help help.Model
44−}
45−
46−type ReasoningEffortSelectedMsg struct {
47− Effort string
48−}
49−
50−type ReasoningDialogKeyMap struct {
51− Next key.Binding
52− Previous key.Binding
53− Select key.Binding
54− Close key.Binding
55−}
56−
57−func DefaultReasoningDialogKeyMap() ReasoningDialogKeyMap {
58− return ReasoningDialogKeyMap{
59− Next: key.NewBinding(
60− key.WithKeys("down", "j", "ctrl+n"),
61− key.WithHelp("↓/j/ctrl+n", "next"),
62− ),
63− Previous: key.NewBinding(
64− key.WithKeys("up", "k", "ctrl+p"),
65− key.WithHelp("↑/k/ctrl+p", "previous"),
66− ),
67− Select: key.NewBinding(
68− key.WithKeys("enter"),
69− key.WithHelp("enter", "select"),
70− ),
71− Close: key.NewBinding(
72− key.WithKeys("esc", "ctrl+c"),
73− key.WithHelp("esc/ctrl+c", "close"),
74− ),
75− }
76−}
77−
78−func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding {
79− return []key.Binding{k.Select, k.Close}
80−}
81−
82−func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
83− return [][]key.Binding{
84− {k.Next, k.Previous},
85− {k.Select, k.Close},
86− }
87−}
88−
89−func NewReasoningDialog() ReasoningDialog {
90− keyMap := DefaultReasoningDialogKeyMap()
91− listKeyMap := list.DefaultKeyMap()
92− listKeyMap.Down.SetEnabled(false)
93− listKeyMap.Up.SetEnabled(false)
94− listKeyMap.DownOneItem = keyMap.Next
95− listKeyMap.UpOneItem = keyMap.Previous
96−
97− t := styles.CurrentTheme()
98− inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
99− effortList := list.NewFilterableList(
100− []list.CompletionItem[EffortOption]{},
101− list.WithFilterInputStyle(inputStyle),
102− list.WithFilterListOptions(
103− list.WithKeyMap(listKeyMap),
104− list.WithWrapNavigation(),
105− list.WithResizeByList(),
106− ),
107− )
108− help := help.New()
109− help.Styles = t.S().Help
110−
111− return &reasoningDialogCmp{
112− effortList: effortList,
113− width: defaultWidth,
114− keyMap: keyMap,
115− help: help,
116− }
117−}
118−
119−func (r *reasoningDialogCmp) Init() tea.Cmd {
120− return r.populateEffortOptions()
121−}
122−
123−func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
124− cfg := config.Get()
125− if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
126− selectedModel := cfg.Models[agentCfg.Model]
127− model := cfg.GetModelByType(agentCfg.Model)
128−
129− // Get current reasoning effort
130− currentEffort := selectedModel.ReasoningEffort
131− if currentEffort == "" && model != nil {
132− currentEffort = model.DefaultReasoningEffort
133− }
134−
135− efforts := []EffortOption{}
136− caser := cases.Title(language.Und)
137− for _, level := range model.ReasoningLevels {
138− efforts = append(efforts, EffortOption{
139− Title: caser.String(level),
140− Effort: level,
141− })
142− }
143−
144− effortItems := []list.CompletionItem[EffortOption]{}
145− selectedID := ""
146− for _, effort := range efforts {
147− opts := []list.CompletionItemOption{
148− list.WithCompletionID(effort.Effort),
149− }
150− if effort.Effort == currentEffort {
151− opts = append(opts, list.WithCompletionShortcut("current"))
152− selectedID = effort.Effort
153− }
154− effortItems = append(effortItems, list.NewCompletionItem(
155− effort.Title,
156− effort,
157− opts...,
158− ))
159− }
160−
161− cmd := r.effortList.SetItems(effortItems)
162− // Set the current effort as the selected item
163− if currentEffort != "" && selectedID != "" {
164− return tea.Sequence(cmd, r.effortList.SetSelected(selectedID))
165− }
166− return cmd
167− }
168− return nil
169−}
170−
171−func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
172− switch msg := msg.(type) {
173− case tea.WindowSizeMsg:
174− r.wWidth = msg.Width
175− r.wHeight = msg.Height
176− return r, r.effortList.SetSize(r.listWidth(), r.listHeight())
177− case tea.KeyPressMsg:
178− switch {
179− case key.Matches(msg, r.keyMap.Select):
180− selectedItem := r.effortList.SelectedItem()
181− if selectedItem == nil {
182− return r, nil // No item selected, do nothing
183− }
184− effort := (*selectedItem).Value()
185− return r, tea.Sequence(
186− util.CmdHandler(dialogs.CloseDialogMsg{}),
187− func() tea.Msg {
188− return ReasoningEffortSelectedMsg{
189− Effort: effort.Effort,
190− }
191− },
192− )
193− case key.Matches(msg, r.keyMap.Close):
194− return r, util.CmdHandler(dialogs.CloseDialogMsg{})
195− default:
196− u, cmd := r.effortList.Update(msg)
197− r.effortList = u.(listModel)
198− return r, cmd
199− }
200− }
201− return r, nil
202−}
203−
204−func (r *reasoningDialogCmp) View() string {
205− t := styles.CurrentTheme()
206− listView := r.effortList
207−
208− header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4))
209− content := lipgloss.JoinVertical(
210− lipgloss.Left,
211− header,
212− listView.View(),
213− "",
214− t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)),
215− )
216− return r.style().Render(content)
217−}
218−
219−func (r *reasoningDialogCmp) Cursor() *tea.Cursor {
220− if cursor, ok := r.effortList.(util.Cursor); ok {
221− cursor := cursor.Cursor()
222− if cursor != nil {
223− cursor = r.moveCursor(cursor)
224− }
225− return cursor
226− }
227− return nil
228−}
229−
230−func (r *reasoningDialogCmp) listWidth() int {
231− return r.width - 2 // 4 for padding
232−}
233−
234−func (r *reasoningDialogCmp) listHeight() int {
235− listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
236− return min(listHeight, r.wHeight/2)
237−}
238−
239−func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
240− row, col := r.Position()
241− offset := row + 3
242− cursor.Y += offset
243− cursor.X = cursor.X + col + 2
244− return cursor
245−}
246−
247−func (r *reasoningDialogCmp) style() lipgloss.Style {
248− t := styles.CurrentTheme()
249− return t.S().Base.
250− Width(r.width).
251− Border(lipgloss.RoundedBorder()).
252− BorderForeground(t.BorderFocus)
253−}
254−
255−func (r *reasoningDialogCmp) Position() (int, int) {
256− row := r.wHeight/4 - 2 // just a bit above the center
257− col := r.wWidth / 2
258− col -= r.width / 2
259− return row, col
260−}
261−
262−func (r *reasoningDialogCmp) ID() dialogs.DialogID {
263− return ReasoningDialogID
264−}
internal/tui/components/dialogs/sessions/keys.godeleted−67
@@ -1,67 +0,0 @@
1−package sessions
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Select,
9− Next,
10− Previous,
11− Close key.Binding
12−}
13−
14−func DefaultKeyMap() KeyMap {
15− return KeyMap{
16− Select: key.NewBinding(
17− key.WithKeys("enter", "tab", "ctrl+y"),
18− key.WithHelp("enter", "choose"),
19− ),
20− Next: key.NewBinding(
21− key.WithKeys("down", "ctrl+n"),
22− key.WithHelp("↓", "next item"),
23− ),
24− Previous: key.NewBinding(
25− key.WithKeys("up", "ctrl+p"),
26− key.WithHelp("↑", "previous item"),
27− ),
28− Close: key.NewBinding(
29− key.WithKeys("esc", "alt+esc"),
30− key.WithHelp("esc", "exit"),
31− ),
32− }
33−}
34−
35−// KeyBindings implements layout.KeyMapProvider
36−func (k KeyMap) KeyBindings() []key.Binding {
37− return []key.Binding{
38− k.Select,
39− k.Next,
40− k.Previous,
41− k.Close,
42− }
43−}
44−
45−// FullHelp implements help.KeyMap.
46−func (k KeyMap) FullHelp() [][]key.Binding {
47− m := [][]key.Binding{}
48− slice := k.KeyBindings()
49− for i := 0; i < len(slice); i += 4 {
50− end := min(i+4, len(slice))
51− m = append(m, slice[i:end])
52− }
53− return m
54−}
55−
56−// ShortHelp implements help.KeyMap.
57−func (k KeyMap) ShortHelp() []key.Binding {
58− return []key.Binding{
59− key.NewBinding(
60−
61− key.WithKeys("down", "up"),
62− key.WithHelp("↑↓", "choose"),
63− ),
64− k.Select,
65− k.Close,
66− }
67−}
internal/tui/components/dialogs/sessions/sessions.godeleted−181
@@ -1,181 +0,0 @@
1−package sessions
2−
3−import (
4− "charm.land/bubbles/v2/help"
5− "charm.land/bubbles/v2/key"
6− tea "charm.land/bubbletea/v2"
7− "charm.land/lipgloss/v2"
8− "github.com/charmbracelet/crush/internal/event"
9− "github.com/charmbracelet/crush/internal/session"
10− "github.com/charmbracelet/crush/internal/tui/components/chat"
11− "github.com/charmbracelet/crush/internal/tui/components/core"
12− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
13− "github.com/charmbracelet/crush/internal/tui/exp/list"
14− "github.com/charmbracelet/crush/internal/tui/styles"
15− "github.com/charmbracelet/crush/internal/tui/util"
16−)
17−
18−const SessionsDialogID dialogs.DialogID = "sessions"
19−
20−// SessionDialog interface for the session switching dialog
21−type SessionDialog interface {
22− dialogs.DialogModel
23−}
24−
25−type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
26−
27−type sessionDialogCmp struct {
28− selectedInx int
29− wWidth int
30− wHeight int
31− width int
32− selectedSessionID string
33− keyMap KeyMap
34− sessionsList SessionsList
35− help help.Model
36−}
37−
38−// NewSessionDialogCmp creates a new session switching dialog
39−func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog {
40− t := styles.CurrentTheme()
41− listKeyMap := list.DefaultKeyMap()
42− keyMap := DefaultKeyMap()
43− listKeyMap.Down.SetEnabled(false)
44− listKeyMap.Up.SetEnabled(false)
45− listKeyMap.DownOneItem = keyMap.Next
46− listKeyMap.UpOneItem = keyMap.Previous
47−
48− items := make([]list.CompletionItem[session.Session], len(sessions))
49− if len(sessions) > 0 {
50− for i, session := range sessions {
51− items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
52− }
53− }
54−
55− inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
56− sessionsList := list.NewFilterableList(
57− items,
58− list.WithFilterPlaceholder("Enter a session name"),
59− list.WithFilterInputStyle(inputStyle),
60− list.WithFilterListOptions(
61− list.WithKeyMap(listKeyMap),
62− list.WithWrapNavigation(),
63− ),
64− )
65− help := help.New()
66− help.Styles = t.S().Help
67− s := &sessionDialogCmp{
68− selectedSessionID: selectedID,
69− keyMap: DefaultKeyMap(),
70− sessionsList: sessionsList,
71− help: help,
72− }
73−
74− return s
75−}
76−
77−func (s *sessionDialogCmp) Init() tea.Cmd {
78− var cmds []tea.Cmd
79− cmds = append(cmds, s.sessionsList.Init())
80− cmds = append(cmds, s.sessionsList.Focus())
81− return tea.Sequence(cmds...)
82−}
83−
84−func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
85− switch msg := msg.(type) {
86− case tea.WindowSizeMsg:
87− var cmds []tea.Cmd
88− s.wWidth = msg.Width
89− s.wHeight = msg.Height
90− s.width = min(120, s.wWidth-8)
91− s.sessionsList.SetInputWidth(s.listWidth() - 2)
92− cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
93− if s.selectedSessionID != "" {
94− cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
95− }
96− return s, tea.Batch(cmds...)
97− case tea.KeyPressMsg:
98− switch {
99− case key.Matches(msg, s.keyMap.Select):
100− selectedItem := s.sessionsList.SelectedItem()
101− if selectedItem != nil {
102− selected := *selectedItem
103− event.SessionSwitched()
104− return s, tea.Sequence(
105− util.CmdHandler(dialogs.CloseDialogMsg{}),
106− util.CmdHandler(
107− chat.SessionSelectedMsg(selected.Value()),
108− ),
109− )
110− }
111− case key.Matches(msg, s.keyMap.Close):
112− return s, util.CmdHandler(dialogs.CloseDialogMsg{})
113− default:
114− u, cmd := s.sessionsList.Update(msg)
115− s.sessionsList = u.(SessionsList)
116− return s, cmd
117− }
118− }
119− return s, nil
120−}
121−
122−func (s *sessionDialogCmp) View() string {
123− t := styles.CurrentTheme()
124− listView := s.sessionsList.View()
125− content := lipgloss.JoinVertical(
126− lipgloss.Left,
127− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)),
128− listView,
129− "",
130− t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)),
131− )
132−
133− return s.style().Render(content)
134−}
135−
136−func (s *sessionDialogCmp) Cursor() *tea.Cursor {
137− if cursor, ok := s.sessionsList.(util.Cursor); ok {
138− cursor := cursor.Cursor()
139− if cursor != nil {
140− cursor = s.moveCursor(cursor)
141− }
142− return cursor
143− }
144− return nil
145−}
146−
147−func (s *sessionDialogCmp) style() lipgloss.Style {
148− t := styles.CurrentTheme()
149− return t.S().Base.
150− Width(s.width).
151− Border(lipgloss.RoundedBorder()).
152− BorderForeground(t.BorderFocus)
153−}
154−
155−func (s *sessionDialogCmp) listHeight() int {
156− return s.wHeight/2 - 6 // 5 for the border, title and help
157−}
158−
159−func (s *sessionDialogCmp) listWidth() int {
160− return s.width - 2 // 2 for the border
161−}
162−
163−func (s *sessionDialogCmp) Position() (int, int) {
164− row := s.wHeight/4 - 2 // just a bit above the center
165− col := s.wWidth / 2
166− col -= s.width / 2
167− return row, col
168−}
169−
170−func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
171− row, col := s.Position()
172− offset := row + 3 // Border + title
173− cursor.Y += offset
174− cursor.X = cursor.X + col + 2
175− return cursor
176−}
177−
178−// ID implements SessionDialog.
179−func (s *sessionDialogCmp) ID() dialogs.DialogID {
180− return SessionsDialogID
181−}
internal/tui/components/files/files.godeleted−146
@@ -1,146 +0,0 @@
1−package files
2−
3−import (
4− "fmt"
5− "os"
6− "path/filepath"
7− "sort"
8− "strings"
9−
10− "charm.land/lipgloss/v2"
11− "github.com/charmbracelet/x/ansi"
12−
13− "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/history"
16− "github.com/charmbracelet/crush/internal/tui/components/core"
17− "github.com/charmbracelet/crush/internal/tui/styles"
18−)
19−
20−// FileHistory represents a file history with initial and latest versions.
21−type FileHistory struct {
22− InitialVersion history.File
23− LatestVersion history.File
24−}
25−
26−// SessionFile represents a file with its history information.
27−type SessionFile struct {
28− History FileHistory
29− FilePath string
30− Additions int
31− Deletions int
32−}
33−
34−// RenderOptions contains options for rendering file lists.
35−type RenderOptions struct {
36− MaxWidth int
37− MaxItems int
38− ShowSection bool
39− SectionName string
40−}
41−
42−// RenderFileList renders a list of file status items with the given options.
43−func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
44− t := styles.CurrentTheme()
45− fileList := []string{}
46−
47− if opts.ShowSection {
48− sectionName := opts.SectionName
49− if sectionName == "" {
50− sectionName = "Modified Files"
51− }
52− section := t.S().Subtle.Render(sectionName)
53− fileList = append(fileList, section, "")
54− }
55−
56− if len(fileSlice) == 0 {
57− fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None"))
58− return fileList
59− }
60−
61− // Sort files by the latest version's created time
62− sort.Slice(fileSlice, func(i, j int) bool {
63− if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt {
64− return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0
65− }
66− return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
67− })
68−
69− // Determine how many items to show
70− maxItems := len(fileSlice)
71− if opts.MaxItems > 0 {
72− maxItems = min(opts.MaxItems, len(fileSlice))
73− }
74−
75− filesShown := 0
76− for _, file := range fileSlice {
77− if file.Additions == 0 && file.Deletions == 0 {
78− continue // skip files with no changes
79− }
80− if filesShown >= maxItems {
81− break
82− }
83−
84− var statusParts []string
85− if file.Additions > 0 {
86− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
87− }
88− if file.Deletions > 0 {
89− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
90− }
91−
92− extraContent := strings.Join(statusParts, " ")
93− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
94− filePath := file.FilePath
95− if rel, err := filepath.Rel(cwd, filePath); err == nil {
96− filePath = rel
97− }
98− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
99− filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…")
100−
101− fileList = append(fileList,
102− core.Status(
103− core.StatusOpts{
104− Title: filePath,
105− ExtraContent: extraContent,
106− },
107− opts.MaxWidth,
108− ),
109− )
110− filesShown++
111− }
112−
113− return fileList
114−}
115−
116−// RenderFileBlock renders a complete file block with optional truncation indicator.
117−func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
118− t := styles.CurrentTheme()
119− fileList := RenderFileList(fileSlice, opts)
120−
121− // Add truncation indicator if needed
122− if showTruncationIndicator && opts.MaxItems > 0 {
123− totalFilesWithChanges := 0
124− for _, file := range fileSlice {
125− if file.Additions > 0 || file.Deletions > 0 {
126− totalFilesWithChanges++
127− }
128− }
129− if totalFilesWithChanges > opts.MaxItems {
130− remaining := totalFilesWithChanges - opts.MaxItems
131− if remaining == 1 {
132− fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
133− } else {
134− fileList = append(fileList,
135− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
136− )
137− }
138− }
139− }
140−
141− content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
142− if opts.MaxWidth > 0 {
143− return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
144− }
145− return content
146−}
internal/tui/components/image/image.godeleted−86
@@ -1,86 +0,0 @@
1−// Based on the implementation by @trashhalo at:
2−// https://github.com/trashhalo/imgcat
3−package image
4−
5−import (
6− "fmt"
7− _ "image/jpeg"
8− _ "image/png"
9−
10− tea "charm.land/bubbletea/v2"
11−)
12−
13−type Model struct {
14− url string
15− image string
16− width uint
17− height uint
18− err error
19−}
20−
21−func New(width, height uint, url string) Model {
22− return Model{
23− width: width,
24− height: height,
25− url: url,
26− }
27−}
28−
29−func (m Model) Init() tea.Cmd {
30− return nil
31−}
32−
33−func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
34− switch msg := msg.(type) {
35− case errMsg:
36− m.err = msg
37− return m, nil
38− case redrawMsg:
39− m.width = msg.width
40− m.height = msg.height
41− m.url = msg.url
42− return m, loadURL(m.url)
43− case loadMsg:
44− return handleLoadMsg(m, msg)
45− }
46− return m, nil
47−}
48−
49−func (m Model) View() string {
50− if m.err != nil {
51− return fmt.Sprintf("couldn't load image(s): %v", m.err)
52− }
53− return m.image
54−}
55−
56−type errMsg struct{ error }
57−
58−func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
59− return func() tea.Msg {
60− return redrawMsg{
61− width: width,
62− height: height,
63− url: url,
64− }
65− }
66−}
67−
68−func (m Model) UpdateURL(url string) tea.Cmd {
69− return func() tea.Msg {
70− return redrawMsg{
71− width: m.width,
72− height: m.height,
73− url: url,
74− }
75− }
76−}
77−
78−type redrawMsg struct {
79− width uint
80− height uint
81− url string
82−}
83−
84−func (m Model) IsLoading() bool {
85− return m.image == ""
86−}
internal/tui/components/image/load.godeleted−169
@@ -1,169 +0,0 @@
1−// Based on the implementation by @trashhalo at:
2−// https://github.com/trashhalo/imgcat
3−package image
4−
5−import (
6− "bytes"
7− "context"
8− "encoding/base64"
9− "image"
10− "image/png"
11− "io"
12− "net/http"
13− "os"
14− "strings"
15−
16− tea "charm.land/bubbletea/v2"
17− "github.com/disintegration/imageorient"
18− "github.com/lucasb-eyer/go-colorful"
19− "github.com/muesli/termenv"
20− "github.com/nfnt/resize"
21− "github.com/srwiley/oksvg"
22− "github.com/srwiley/rasterx"
23−)
24−
25−type loadMsg struct {
26− io.ReadCloser
27−}
28−
29−func loadURL(url string) tea.Cmd {
30− var r io.ReadCloser
31− var err error
32−
33− if strings.HasPrefix(url, "http") {
34− var resp *http.Request
35− resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
36− r = resp.Body
37− } else {
38− r, err = os.Open(url)
39− }
40−
41− if err != nil {
42− return func() tea.Msg {
43− return errMsg{err}
44− }
45− }
46−
47− return load(r)
48−}
49−
50−func load(r io.ReadCloser) tea.Cmd {
51− return func() tea.Msg {
52− return loadMsg{r}
53− }
54−}
55−
56−func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
57− defer msg.Close()
58−
59− img, err := readerToImage(m.width, m.height, m.url, msg)
60− if err != nil {
61− return m, func() tea.Msg { return errMsg{err} }
62− }
63− m.image = img
64− return m, nil
65−}
66−
67−func imageToString(width, height uint, img image.Image) (string, error) {
68− img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
69− b := img.Bounds()
70− w := b.Max.X
71− h := b.Max.Y
72− p := termenv.ColorProfile()
73− str := strings.Builder{}
74− for y := 0; y < h; y += 2 {
75− for x := w; x < int(width); x = x + 2 {
76− str.WriteString(" ")
77− }
78− for x := range w {
79− c1, _ := colorful.MakeColor(img.At(x, y))
80− color1 := p.Color(c1.Hex())
81− c2, _ := colorful.MakeColor(img.At(x, y+1))
82− color2 := p.Color(c2.Hex())
83− str.WriteString(termenv.String("▀").
84− Foreground(color1).
85− Background(color2).
86− String())
87− }
88− str.WriteString("\n")
89− }
90− return str.String(), nil
91−}
92−
93−func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) {
94− if strings.HasSuffix(strings.ToLower(url), ".svg") {
95− return svgToImage(width, height, r)
96− }
97−
98− img, _, err := imageorient.Decode(r)
99− if err != nil {
100− return "", err
101− }
102−
103− return imageToString(width, height, img)
104−}
105−
106−func svgToImage(width uint, height uint, r io.Reader) (string, error) {
107− // Original author: https://stackoverflow.com/users/10826783/usual-human
108− // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
109− // Adapted to use size from SVG, and to use temp file.
110−
111− tmpPngFile, err := os.CreateTemp("", "img.*.png")
112− if err != nil {
113− return "", err
114− }
115− tmpPngPath := tmpPngFile.Name()
116− defer os.Remove(tmpPngPath)
117− defer tmpPngFile.Close()
118−
119− // Rasterize the SVG:
120− icon, err := oksvg.ReadIconStream(r)
121− if err != nil {
122− return "", err
123− }
124− w := int(icon.ViewBox.W)
125− h := int(icon.ViewBox.H)
126− icon.SetTarget(0, 0, float64(w), float64(h))
127− rgba := image.NewRGBA(image.Rect(0, 0, w, h))
128− icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1)
129− // Write rasterized image as PNG:
130− err = png.Encode(tmpPngFile, rgba)
131− if err != nil {
132− tmpPngFile.Close()
133− return "", err
134− }
135− tmpPngFile.Close()
136−
137− rPng, err := os.Open(tmpPngPath)
138− if err != nil {
139− return "", err
140− }
141− defer rPng.Close()
142−
143− img, _, err := imageorient.Decode(rPng)
144− if err != nil {
145− return "", err
146− }
147− return imageToString(width, height, img)
148−}
149−
150−// ImageFromBase64 renders an image from base64-encoded data.
151−func ImageFromBase64(width, height uint, data, mediaType string) (string, error) {
152− decoded, err := base64.StdEncoding.DecodeString(data)
153− if err != nil {
154− return "", err
155− }
156−
157− r := bytes.NewReader(decoded)
158−
159− if strings.Contains(mediaType, "svg") {
160− return svgToImage(width, height, r)
161− }
162−
163− img, _, err := imageorient.Decode(r)
164− if err != nil {
165− return "", err
166− }
167−
168− return imageToString(width, height, img)
169−}
internal/tui/components/logo/logo.godeleted−346
@@ -1,346 +0,0 @@
1−// Package logo renders a Crush wordmark in a stylized way.
2−package logo
3−
4−import (
5− "fmt"
6− "image/color"
7− "strings"
8−
9− "charm.land/lipgloss/v2"
10− "github.com/MakeNowJust/heredoc"
11− "github.com/charmbracelet/crush/internal/tui/styles"
12− "github.com/charmbracelet/x/ansi"
13− "github.com/charmbracelet/x/exp/slice"
14−)
15−
16−// letterform represents a letterform. It can be stretched horizontally by
17−// a given amount via the boolean argument.
18−type letterform func(bool) string
19−
20−const diag = `╱`
21−
22−// Opts are the options for rendering the Crush title art.
23−type Opts struct {
24− FieldColor color.Color // diagonal lines
25− TitleColorA color.Color // left gradient ramp point
26− TitleColorB color.Color // right gradient ramp point
27− CharmColor color.Color // Charm™ text color
28− VersionColor color.Color // Version text color
29− Width int // width of the rendered logo, used for truncation
30−}
31−
32−// Render renders the Crush logo. Set the argument to true to render the narrow
33−// version, intended for use in a sidebar.
34−//
35−// The compact argument determines whether it renders compact for the sidebar
36−// or wider for the main pane.
37−func Render(version string, compact bool, o Opts) string {
38− const charm = " Charm™"
39−
40− fg := func(c color.Color, s string) string {
41− return lipgloss.NewStyle().Foreground(c).Render(s)
42− }
43−
44− // Title.
45− const spacing = 1
46− letterforms := []letterform{
47− letterC,
48− letterR,
49− letterU,
50− letterSStylized,
51− letterH,
52− }
53− stretchIndex := -1 // -1 means no stretching.
54− if !compact {
55− stretchIndex = cachedRandN(len(letterforms))
56− }
57−
58− crush := renderWord(spacing, stretchIndex, letterforms...)
59− crushWidth := lipgloss.Width(crush)
60− b := new(strings.Builder)
61− for r := range strings.SplitSeq(crush, "\n") {
62− fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
63− }
64− crush = b.String()
65−
66− // Charm and version.
67− metaRowGap := 1
68− maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
69− version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
70− gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
71− metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
72−
73− // Join the meta row and big Crush title.
74− crush = strings.TrimSpace(metaRow + "\n" + crush)
75−
76− // Narrow version.
77− if compact {
78− field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
79− return strings.Join([]string{field, field, crush, field, ""}, "\n")
80− }
81−
82− fieldHeight := lipgloss.Height(crush)
83−
84− // Left field.
85− const leftWidth = 6
86− leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
87− leftField := new(strings.Builder)
88− for range fieldHeight {
89− fmt.Fprintln(leftField, leftFieldRow)
90− }
91−
92− // Right field.
93− rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
94− const stepDownAt = 0
95− rightField := new(strings.Builder)
96− for i := range fieldHeight {
97− width := rightWidth
98− if i >= stepDownAt {
99− width = rightWidth - (i - stepDownAt)
100− }
101− fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
102− }
103−
104− // Return the wide version.
105− const hGap = " "
106− logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
107− if o.Width > 0 {
108− // Truncate the logo to the specified width.
109− lines := strings.Split(logo, "\n")
110− for i, line := range lines {
111− lines[i] = ansi.Truncate(line, o.Width, "")
112− }
113− logo = strings.Join(lines, "\n")
114− }
115− return logo
116−}
117−
118−// SmallRender renders a smaller version of the Crush logo, suitable for
119−// smaller windows or sidebar usage.
120−func SmallRender(width int) string {
121− t := styles.CurrentTheme()
122− title := t.S().Base.Foreground(t.Secondary).Render("Charm™")
123− title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
124− remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
125− if remainingWidth > 0 {
126− lines := strings.Repeat("╱", remainingWidth)
127− title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
128− }
129− return title
130−}
131−
132−// renderWord renders letterforms to fork a word. stretchIndex is the index of
133−// the letter to stretch, or -1 if no letter should be stretched.
134−func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
135− if spacing < 0 {
136− spacing = 0
137− }
138−
139− renderedLetterforms := make([]string, len(letterforms))
140−
141− // pick one letter randomly to stretch
142− for i, letter := range letterforms {
143− renderedLetterforms[i] = letter(i == stretchIndex)
144− }
145−
146− if spacing > 0 {
147− // Add spaces between the letters and render.
148− renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
149− }
150− return strings.TrimSpace(
151− lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
152− )
153−}
154−
155−// letterC renders the letter C in a stylized way. It takes an integer that
156−// determines how many cells to stretch the letter. If the stretch is less than
157−// 1, it defaults to no stretching.
158−func letterC(stretch bool) string {
159− // Here's what we're making:
160− //
161− // ▄▀▀▀▀
162− // █
163− // ▀▀▀▀
164−
165− left := heredoc.Doc(`
166− ▄
167− █
168− `)
169− right := heredoc.Doc(`
170− ▀
171−
172− ▀
173− `)
174− return joinLetterform(
175− left,
176− stretchLetterformPart(right, letterformProps{
177− stretch: stretch,
178− width: 4,
179− minStretch: 7,
180− maxStretch: 12,
181− }),
182− )
183−}
184−
185−// letterH renders the letter H in a stylized way. It takes an integer that
186−// determines how many cells to stretch the letter. If the stretch is less than
187−// 1, it defaults to no stretching.
188−func letterH(stretch bool) string {
189− // Here's what we're making:
190− //
191− // █ █
192− // █▀▀▀█
193− // ▀ ▀
194−
195− side := heredoc.Doc(`
196− █
197− █
198− ▀`)
199− middle := heredoc.Doc(`
200−
201− ▀
202− `)
203− return joinLetterform(
204− side,
205− stretchLetterformPart(middle, letterformProps{
206− stretch: stretch,
207− width: 3,
208− minStretch: 8,
209− maxStretch: 12,
210− }),
211− side,
212− )
213−}
214−
215−// letterR renders the letter R in a stylized way. It takes an integer that
216−// determines how many cells to stretch the letter. If the stretch is less than
217−// 1, it defaults to no stretching.
218−func letterR(stretch bool) string {
219− // Here's what we're making:
220− //
221− // █▀▀▀▄
222− // █▀▀▀▄
223− // ▀ ▀
224−
225− left := heredoc.Doc(`
226− █
227− █
228− ▀
229− `)
230− center := heredoc.Doc(`
231− ▀
232− ▀
233− `)
234− right := heredoc.Doc(`
235− ▄
236− ▄
237− ▀
238− `)
239− return joinLetterform(
240− left,
241− stretchLetterformPart(center, letterformProps{
242− stretch: stretch,
243− width: 3,
244− minStretch: 7,
245− maxStretch: 12,
246− }),
247− right,
248− )
249−}
250−
251−// letterSStylized renders the letter S in a stylized way, more so than
252−// [letterS]. It takes an integer that determines how many cells to stretch the
253−// letter. If the stretch is less than 1, it defaults to no stretching.
254−func letterSStylized(stretch bool) string {
255− // Here's what we're making:
256− //
257− // ▄▀▀▀▀▀
258− // ▀▀▀▀▀█
259− // ▀▀▀▀▀
260−
261− left := heredoc.Doc(`
262− ▄
263− ▀
264− ▀
265− `)
266− center := heredoc.Doc(`
267− ▀
268− ▀
269− ▀
270− `)
271− right := heredoc.Doc(`
272− ▀
273− █
274− `)
275− return joinLetterform(
276− left,
277− stretchLetterformPart(center, letterformProps{
278− stretch: stretch,
279− width: 3,
280− minStretch: 7,
281− maxStretch: 12,
282− }),
283− right,
284− )
285−}
286−
287−// letterU renders the letter U in a stylized way. It takes an integer that
288−// determines how many cells to stretch the letter. If the stretch is less than
289−// 1, it defaults to no stretching.
290−func letterU(stretch bool) string {
291− // Here's what we're making:
292− //
293− // █ █
294− // █ █
295− // ▀▀▀
296−
297− side := heredoc.Doc(`
298− █
299− █
300− `)
301− middle := heredoc.Doc(`
302−
303−
304− ▀
305− `)
306− return joinLetterform(
307− side,
308− stretchLetterformPart(middle, letterformProps{
309− stretch: stretch,
310− width: 3,
311− minStretch: 7,
312− maxStretch: 12,
313− }),
314− side,
315− )
316−}
317−
318−func joinLetterform(letters ...string) string {
319− return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
320−}
321−
322−// letterformProps defines letterform stretching properties.
323−// for readability.
324−type letterformProps struct {
325− width int
326− minStretch int
327− maxStretch int
328− stretch bool
329−}
330−
331−// stretchLetterformPart is a helper function for letter stretching. If randomize
332−// is false the minimum number will be used.
333−func stretchLetterformPart(s string, p letterformProps) string {
334− if p.maxStretch < p.minStretch {
335− p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
336− }
337− n := p.width
338− if p.stretch {
339− n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
340− }
341− parts := make([]string, n)
342− for i := range parts {
343− parts[i] = s
344− }
345− return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
346−}
internal/tui/components/logo/rand.godeleted−24
@@ -1,24 +0,0 @@
1−package logo
2−
3−import (
4− "math/rand/v2"
5− "sync"
6−)
7−
8−var (
9− randCaches = make(map[int]int)
10− randCachesMu sync.Mutex
11−)
12−
13−func cachedRandN(n int) int {
14− randCachesMu.Lock()
15− defer randCachesMu.Unlock()
16−
17− if n, ok := randCaches[n]; ok {
18− return n
19− }
20−
21− r := rand.IntN(n)
22− randCaches[n] = r
23− return r
24−}
internal/tui/components/lsp/lsp.godeleted−144
@@ -1,144 +0,0 @@
1−package lsp
2−
3−import (
4− "fmt"
5− "maps"
6− "slices"
7− "strings"
8−
9− "charm.land/lipgloss/v2"
10− "github.com/charmbracelet/crush/internal/app"
11− "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/csync"
13− "github.com/charmbracelet/crush/internal/lsp"
14− "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/styles"
16−)
17−
18−// RenderOptions contains options for rendering LSP lists.
19−type RenderOptions struct {
20− MaxWidth int
21− MaxItems int
22− ShowSection bool
23− SectionName string
24−}
25−
26−// RenderLSPList renders a list of LSP status items with the given options.
27−func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions) []string {
28− t := styles.CurrentTheme()
29− lspList := []string{}
30−
31− if opts.ShowSection {
32− sectionName := opts.SectionName
33− if sectionName == "" {
34− sectionName = "LSPs"
35− }
36− section := t.S().Subtle.Render(sectionName)
37− lspList = append(lspList, section, "")
38− }
39−
40− // Get LSP states
41− lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
42− return strings.Compare(a.Name, b.Name)
43− })
44− if len(lsps) == 0 {
45− lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
46− return lspList
47− }
48−
49− // Determine how many items to show
50− maxItems := len(lsps)
51− if opts.MaxItems > 0 {
52− maxItems = min(opts.MaxItems, len(lsps))
53− }
54−
55− for i, info := range lsps {
56− if i >= maxItems {
57− break
58− }
59−
60− icon, description := iconAndDescription(t, info)
61−
62− // Calculate diagnostic counts if we have LSP clients
63− var extraContent string
64− if lspClients != nil {
65− if client, ok := lspClients.Get(info.Name); ok {
66− counts := client.GetDiagnosticCounts()
67− errs := []string{}
68− if counts.Error > 0 {
69− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, counts.Error)))
70− }
71− if counts.Warning > 0 {
72− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, counts.Warning)))
73− }
74− if counts.Hint > 0 {
75− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, counts.Hint)))
76− }
77− if counts.Information > 0 {
78− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, counts.Information)))
79− }
80− extraContent = strings.Join(errs, " ")
81− }
82− }
83−
84− lspList = append(lspList,
85− core.Status(
86− core.StatusOpts{
87− Icon: icon.String(),
88− Title: info.Name,
89− Description: description,
90− ExtraContent: extraContent,
91− },
92− opts.MaxWidth,
93− ),
94− )
95− }
96−
97− return lspList
98−}
99−
100−func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
101− switch info.State {
102− case lsp.StateStarting:
103− return t.ItemBusyIcon, t.S().Subtle.Render("starting...")
104− case lsp.StateReady:
105− return t.ItemOnlineIcon, ""
106− case lsp.StateError:
107− description := t.S().Subtle.Render("error")
108− if info.Error != nil {
109− description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error()))
110− }
111− return t.ItemErrorIcon, description
112− case lsp.StateDisabled:
113− return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive")
114− default:
115− return t.ItemOfflineIcon, ""
116− }
117−}
118−
119−// RenderLSPBlock renders a complete LSP block with optional truncation indicator.
120−func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string {
121− t := styles.CurrentTheme()
122− lspList := RenderLSPList(lspClients, opts)
123−
124− // Add truncation indicator if needed
125− if showTruncationIndicator && opts.MaxItems > 0 {
126− lspConfigs := config.Get().LSP.Sorted()
127− if len(lspConfigs) > opts.MaxItems {
128− remaining := len(lspConfigs) - opts.MaxItems
129− if remaining == 1 {
130− lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
131− } else {
132− lspList = append(lspList,
133− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
134− )
135− }
136− }
137− }
138−
139− content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
140− if opts.MaxWidth > 0 {
141− return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
142− }
143− return content
144−}
internal/tui/components/mcp/mcp.godeleted−138
@@ -1,138 +0,0 @@
1−package mcp
2−
3−import (
4− "fmt"
5− "strings"
6−
7− "charm.land/lipgloss/v2"
8−
9− "github.com/charmbracelet/crush/internal/agent/tools/mcp"
10− "github.com/charmbracelet/crush/internal/config"
11− "github.com/charmbracelet/crush/internal/tui/components/core"
12− "github.com/charmbracelet/crush/internal/tui/styles"
13−)
14−
15−// RenderOptions contains options for rendering MCP lists.
16−type RenderOptions struct {
17− MaxWidth int
18− MaxItems int
19− ShowSection bool
20− SectionName string
21−}
22−
23−// RenderMCPList renders a list of MCP status items with the given options.
24−func RenderMCPList(opts RenderOptions) []string {
25− t := styles.CurrentTheme()
26− mcpList := []string{}
27−
28− if opts.ShowSection {
29− sectionName := opts.SectionName
30− if sectionName == "" {
31− sectionName = "MCPs"
32− }
33− section := t.S().Subtle.Render(sectionName)
34− mcpList = append(mcpList, section, "")
35− }
36−
37− mcps := config.Get().MCP.Sorted()
38− if len(mcps) == 0 {
39− mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None"))
40− return mcpList
41− }
42−
43− // Get MCP states
44− mcpStates := mcp.GetStates()
45−
46− // Determine how many items to show
47− maxItems := len(mcps)
48− if opts.MaxItems > 0 {
49− maxItems = min(opts.MaxItems, len(mcps))
50− }
51−
52− for i, l := range mcps {
53− if i >= maxItems {
54− break
55− }
56−
57− // Determine icon and color based on state
58− icon := t.ItemOfflineIcon
59− description := ""
60− extraContent := []string{}
61−
62− if state, exists := mcpStates[l.Name]; exists {
63− switch state.State {
64− case mcp.StateDisabled:
65− description = t.S().Subtle.Render("disabled")
66− case mcp.StateStarting:
67− icon = t.ItemBusyIcon
68− description = t.S().Subtle.Render("starting...")
69− case mcp.StateConnected:
70− icon = t.ItemOnlineIcon
71− if count := state.Counts.Tools; count > 0 {
72− label := "tools"
73− if count == 1 {
74− label = "tool"
75− }
76− extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label)))
77− }
78− if count := state.Counts.Prompts; count > 0 {
79− label := "prompts"
80− if count == 1 {
81− label = "prompt"
82− }
83− extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label)))
84− }
85− case mcp.StateError:
86− icon = t.ItemErrorIcon
87− if state.Error != nil {
88− description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
89− } else {
90− description = t.S().Subtle.Render("error")
91− }
92− }
93− } else if l.MCP.Disabled {
94− description = t.S().Subtle.Render("disabled")
95− }
96−
97− mcpList = append(mcpList,
98− core.Status(
99− core.StatusOpts{
100− Icon: icon.String(),
101− Title: l.Name,
102− Description: description,
103− ExtraContent: strings.Join(extraContent, " "),
104− },
105− opts.MaxWidth,
106− ),
107− )
108− }
109−
110− return mcpList
111−}
112−
113−// RenderMCPBlock renders a complete MCP block with optional truncation indicator.
114−func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string {
115− t := styles.CurrentTheme()
116− mcpList := RenderMCPList(opts)
117−
118− // Add truncation indicator if needed
119− if showTruncationIndicator && opts.MaxItems > 0 {
120− mcps := config.Get().MCP.Sorted()
121− if len(mcps) > opts.MaxItems {
122− remaining := len(mcps) - opts.MaxItems
123− if remaining == 1 {
124− mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
125− } else {
126− mcpList = append(mcpList,
127− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
128− )
129− }
130− }
131− }
132−
133− content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
134− if opts.MaxWidth > 0 {
135− return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
136− }
137− return content
138−}
internal/tui/exp/list/filterable.godeleted−329
@@ -1,329 +0,0 @@
1−package list
2−
3−import (
4− "regexp"
5− "slices"
6−
7− "charm.land/bubbles/v2/key"
8− "charm.land/bubbles/v2/textinput"
9− tea "charm.land/bubbletea/v2"
10− "charm.land/lipgloss/v2"
11− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12− "github.com/charmbracelet/crush/internal/tui/styles"
13− "github.com/charmbracelet/crush/internal/tui/util"
14− "github.com/sahilm/fuzzy"
15−)
16−
17−// Pre-compiled regex for checking if a string is alphanumeric.
18−var alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
19−
20−type FilterableItem interface {
21− Item
22− FilterValue() string
23−}
24−
25−type FilterableList[T FilterableItem] interface {
26− List[T]
27− Cursor() *tea.Cursor
28− SetInputWidth(int)
29− SetInputPlaceholder(string)
30− SetResultsSize(int)
31− Filter(q string) tea.Cmd
32− fuzzy.Source
33−}
34−
35−type HasMatchIndexes interface {
36− MatchIndexes([]int)
37−}
38−
39−type filterableOptions struct {
40− listOptions []ListOption
41− placeholder string
42− inputHidden bool
43− inputWidth int
44− inputStyle lipgloss.Style
45−}
46−type filterableList[T FilterableItem] struct {
47− *list[T]
48− *filterableOptions
49− width, height int
50− // stores all available items
51− items []T
52− resultsSize int
53− input textinput.Model
54− inputWidth int
55− query string
56−}
57−
58−type filterableListOption func(*filterableOptions)
59−
60−func WithFilterPlaceholder(ph string) filterableListOption {
61− return func(f *filterableOptions) {
62− f.placeholder = ph
63− }
64−}
65−
66−func WithFilterInputHidden() filterableListOption {
67− return func(f *filterableOptions) {
68− f.inputHidden = true
69− }
70−}
71−
72−func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
73− return func(f *filterableOptions) {
74− f.inputStyle = inputStyle
75− }
76−}
77−
78−func WithFilterListOptions(opts ...ListOption) filterableListOption {
79− return func(f *filterableOptions) {
80− f.listOptions = opts
81− }
82−}
83−
84−func WithFilterInputWidth(inputWidth int) filterableListOption {
85− return func(f *filterableOptions) {
86− f.inputWidth = inputWidth
87− }
88−}
89−
90−func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] {
91− t := styles.CurrentTheme()
92−
93− f := &filterableList[T]{
94− filterableOptions: &filterableOptions{
95− inputStyle: t.S().Base,
96− placeholder: "Type to filter",
97− },
98− }
99− for _, opt := range opts {
100− opt(f.filterableOptions)
101− }
102− f.list = New(items, f.listOptions...).(*list[T])
103−
104− f.updateKeyMaps()
105− f.items = f.list.items
106−
107− if f.inputHidden {
108− return f
109− }
110−
111− ti := textinput.New()
112− ti.Placeholder = f.placeholder
113− ti.SetVirtualCursor(false)
114− ti.Focus()
115− ti.SetStyles(t.S().TextInput)
116− f.input = ti
117− return f
118−}
119−
120−func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
121− switch msg := msg.(type) {
122− case tea.KeyPressMsg:
123− switch {
124− // handle movements
125− case key.Matches(msg, f.keyMap.Down),
126− key.Matches(msg, f.keyMap.Up),
127− key.Matches(msg, f.keyMap.DownOneItem),
128− key.Matches(msg, f.keyMap.UpOneItem),
129− key.Matches(msg, f.keyMap.HalfPageDown),
130− key.Matches(msg, f.keyMap.HalfPageUp),
131− key.Matches(msg, f.keyMap.PageDown),
132− key.Matches(msg, f.keyMap.PageUp),
133− key.Matches(msg, f.keyMap.End),
134− key.Matches(msg, f.keyMap.Home):
135− u, cmd := f.list.Update(msg)
136− f.list = u.(*list[T])
137− return f, cmd
138− default:
139− if !f.inputHidden {
140− var cmds []tea.Cmd
141− var cmd tea.Cmd
142− f.input, cmd = f.input.Update(msg)
143− cmds = append(cmds, cmd)
144−
145− if f.query != f.input.Value() {
146− cmd = f.Filter(f.input.Value())
147− cmds = append(cmds, cmd)
148− }
149− f.query = f.input.Value()
150− return f, tea.Batch(cmds...)
151− }
152− }
153− }
154− u, cmd := f.list.Update(msg)
155− f.list = u.(*list[T])
156− return f, cmd
157−}
158−
159−func (f *filterableList[T]) View() string {
160− if f.inputHidden {
161− return f.list.View()
162− }
163−
164− return lipgloss.JoinVertical(
165− lipgloss.Left,
166− f.inputStyle.Render(f.input.View()),
167− f.list.View(),
168− )
169−}
170−
171−// removes bindings that are used for search
172−func (f *filterableList[T]) updateKeyMaps() {
173− removeLettersAndNumbers := func(bindings []string) []string {
174− var keep []string
175− for _, b := range bindings {
176− if len(b) != 1 {
177− keep = append(keep, b)
178− continue
179− }
180− if b == " " {
181− continue
182− }
183− m := alphanumericRegex.MatchString(b)
184− if !m {
185− keep = append(keep, b)
186− }
187− }
188− return keep
189− }
190−
191− updateBinding := func(binding key.Binding) key.Binding {
192− newKeys := removeLettersAndNumbers(binding.Keys())
193− if len(newKeys) == 0 {
194− binding.SetEnabled(false)
195− return binding
196− }
197− binding.SetKeys(newKeys...)
198− return binding
199− }
200−
201− f.keyMap.Down = updateBinding(f.keyMap.Down)
202− f.keyMap.Up = updateBinding(f.keyMap.Up)
203− f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
204− f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
205− f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
206− f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
207− f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
208− f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
209− f.keyMap.End = updateBinding(f.keyMap.End)
210− f.keyMap.Home = updateBinding(f.keyMap.Home)
211−}
212−
213−func (m *filterableList[T]) GetSize() (int, int) {
214− return m.width, m.height
215−}
216−
217−func (f *filterableList[T]) SetSize(w, h int) tea.Cmd {
218− f.width = w
219− f.height = h
220− if f.inputHidden {
221− return f.list.SetSize(w, h)
222− }
223− if f.inputWidth == 0 {
224− f.input.SetWidth(w)
225− } else {
226− f.input.SetWidth(f.inputWidth)
227− }
228− return f.list.SetSize(w, h-(f.inputHeight()))
229−}
230−
231−func (f *filterableList[T]) inputHeight() int {
232− return lipgloss.Height(f.inputStyle.Render(f.input.View()))
233−}
234−
235−func (f *filterableList[T]) Filter(query string) tea.Cmd {
236− var cmds []tea.Cmd
237− for _, item := range f.items {
238− if i, ok := any(item).(layout.Focusable); ok {
239− cmds = append(cmds, i.Blur())
240− }
241− if i, ok := any(item).(HasMatchIndexes); ok {
242− i.MatchIndexes(make([]int, 0))
243− }
244− }
245−
246− f.selectedItemIdx = -1
247− if query == "" || len(f.items) == 0 {
248− return f.list.SetItems(f.visibleItems(f.items))
249− }
250−
251− matches := fuzzy.FindFrom(query, f)
252−
253− var matchedItems []T
254− resultSize := len(matches)
255− if f.resultsSize > 0 && resultSize > f.resultsSize {
256− resultSize = f.resultsSize
257− }
258− for i := range resultSize {
259− match := matches[i]
260− item := f.items[match.Index]
261− if it, ok := any(item).(HasMatchIndexes); ok {
262− it.MatchIndexes(match.MatchedIndexes)
263− }
264− matchedItems = append(matchedItems, item)
265− }
266−
267− if f.direction == DirectionBackward {
268− slices.Reverse(matchedItems)
269− }
270−
271− cmds = append(cmds, f.list.SetItems(matchedItems))
272− return tea.Batch(cmds...)
273−}
274−
275−func (f *filterableList[T]) SetItems(items []T) tea.Cmd {
276− f.items = items
277− return f.list.SetItems(f.visibleItems(items))
278−}
279−
280−func (f *filterableList[T]) Cursor() *tea.Cursor {
281− if f.inputHidden {
282− return nil
283− }
284− return f.input.Cursor()
285−}
286−
287−func (f *filterableList[T]) Blur() tea.Cmd {
288− f.input.Blur()
289− return f.list.Blur()
290−}
291−
292−func (f *filterableList[T]) Focus() tea.Cmd {
293− f.input.Focus()
294− return f.list.Focus()
295−}
296−
297−func (f *filterableList[T]) IsFocused() bool {
298− return f.list.IsFocused()
299−}
300−
301−func (f *filterableList[T]) SetInputWidth(w int) {
302− f.inputWidth = w
303−}
304−
305−func (f *filterableList[T]) SetInputPlaceholder(ph string) {
306− f.placeholder = ph
307−}
308−
309−func (f *filterableList[T]) SetResultsSize(size int) {
310− f.resultsSize = size
311−}
312−
313−func (f *filterableList[T]) String(i int) string {
314− return f.items[i].FilterValue()
315−}
316−
317−func (f *filterableList[T]) Len() int {
318− return len(f.items)
319−}
320−
321−// visibleItems returns the subset of items that should be rendered based on
322−// the configured resultsSize limit. The underlying source (f.items) remains
323−// intact so filtering still searches the full set.
324−func (f *filterableList[T]) visibleItems(items []T) []T {
325− if f.resultsSize > 0 && len(items) > f.resultsSize {
326− return items[:f.resultsSize]
327− }
328− return items
329−}
internal/tui/exp/list/filterable_group.godeleted−315
@@ -1,315 +0,0 @@
1−package list
2−
3−import (
4− "regexp"
5− "sort"
6− "strings"
7−
8− "charm.land/bubbles/v2/key"
9− "charm.land/bubbles/v2/textinput"
10− tea "charm.land/bubbletea/v2"
11− "charm.land/lipgloss/v2"
12− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
13− "github.com/charmbracelet/crush/internal/tui/styles"
14− "github.com/charmbracelet/crush/internal/tui/util"
15− "github.com/sahilm/fuzzy"
16−)
17−
18−// Pre-compiled regex for checking if a string is alphanumeric.
19−// Note: This is duplicated from filterable.go to avoid circular dependencies.
20−var alphanumericRegexGroup = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
21−
22−type FilterableGroupList[T FilterableItem] interface {
23− GroupedList[T]
24− Cursor() *tea.Cursor
25− SetInputWidth(int)
26− SetInputPlaceholder(string)
27−}
28−type filterableGroupList[T FilterableItem] struct {
29− *groupedList[T]
30− *filterableOptions
31− width, height int
32− groups []Group[T]
33− // stores all available items
34− input textinput.Model
35− inputWidth int
36− query string
37−}
38−
39−func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
40− t := styles.CurrentTheme()
41−
42− f := &filterableGroupList[T]{
43− filterableOptions: &filterableOptions{
44− inputStyle: t.S().Base,
45− placeholder: "Type to filter",
46− },
47− }
48− for _, opt := range opts {
49− opt(f.filterableOptions)
50− }
51− f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
52−
53− f.updateKeyMaps()
54−
55− if f.inputHidden {
56− return f
57− }
58−
59− ti := textinput.New()
60− ti.Placeholder = f.placeholder
61− ti.SetVirtualCursor(false)
62− ti.Focus()
63− ti.SetStyles(t.S().TextInput)
64− f.input = ti
65− return f
66−}
67−
68−func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
69− switch msg := msg.(type) {
70− case tea.KeyPressMsg:
71− switch {
72− // handle movements
73− case key.Matches(msg, f.keyMap.Down),
74− key.Matches(msg, f.keyMap.Up),
75− key.Matches(msg, f.keyMap.DownOneItem),
76− key.Matches(msg, f.keyMap.UpOneItem),
77− key.Matches(msg, f.keyMap.HalfPageDown),
78− key.Matches(msg, f.keyMap.HalfPageUp),
79− key.Matches(msg, f.keyMap.PageDown),
80− key.Matches(msg, f.keyMap.PageUp),
81− key.Matches(msg, f.keyMap.End),
82− key.Matches(msg, f.keyMap.Home):
83− u, cmd := f.groupedList.Update(msg)
84− f.groupedList = u.(*groupedList[T])
85− return f, cmd
86− default:
87− if !f.inputHidden {
88− var cmds []tea.Cmd
89− var cmd tea.Cmd
90− f.input, cmd = f.input.Update(msg)
91− cmds = append(cmds, cmd)
92−
93− if f.query != f.input.Value() {
94− cmd = f.Filter(f.input.Value())
95− cmds = append(cmds, cmd)
96− }
97− f.query = f.input.Value()
98− return f, tea.Batch(cmds...)
99− }
100− }
101− }
102− u, cmd := f.groupedList.Update(msg)
103− f.groupedList = u.(*groupedList[T])
104− return f, cmd
105−}
106−
107−func (f *filterableGroupList[T]) View() string {
108− if f.inputHidden {
109− return f.groupedList.View()
110− }
111−
112− return lipgloss.JoinVertical(
113− lipgloss.Left,
114− f.inputStyle.Render(f.input.View()),
115− f.groupedList.View(),
116− )
117−}
118−
119−// removes bindings that are used for search
120−func (f *filterableGroupList[T]) updateKeyMaps() {
121− removeLettersAndNumbers := func(bindings []string) []string {
122− var keep []string
123− for _, b := range bindings {
124− if len(b) != 1 {
125− keep = append(keep, b)
126− continue
127− }
128− if b == " " {
129− continue
130− }
131− m := alphanumericRegexGroup.MatchString(b)
132− if !m {
133− keep = append(keep, b)
134− }
135− }
136− return keep
137− }
138−
139− updateBinding := func(binding key.Binding) key.Binding {
140− newKeys := removeLettersAndNumbers(binding.Keys())
141− if len(newKeys) == 0 {
142− binding.SetEnabled(false)
143− return binding
144− }
145− binding.SetKeys(newKeys...)
146− return binding
147− }
148−
149− f.keyMap.Down = updateBinding(f.keyMap.Down)
150− f.keyMap.Up = updateBinding(f.keyMap.Up)
151− f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
152− f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
153− f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
154− f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
155− f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
156− f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
157− f.keyMap.End = updateBinding(f.keyMap.End)
158− f.keyMap.Home = updateBinding(f.keyMap.Home)
159−}
160−
161−func (m *filterableGroupList[T]) GetSize() (int, int) {
162− return m.width, m.height
163−}
164−
165−func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
166− f.width = w
167− f.height = h
168− if f.inputHidden {
169− return f.groupedList.SetSize(w, h)
170− }
171− if f.inputWidth == 0 {
172− f.input.SetWidth(w)
173− } else {
174− f.input.SetWidth(f.inputWidth)
175− }
176− return f.groupedList.SetSize(w, h-(f.inputHeight()))
177−}
178−
179−func (f *filterableGroupList[T]) inputHeight() int {
180− return lipgloss.Height(f.inputStyle.Render(f.input.View()))
181−}
182−
183−func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
184− var cmds []tea.Cmd
185− for _, item := range f.items {
186− if i, ok := any(item).(layout.Focusable); ok {
187− cmds = append(cmds, i.Blur())
188− }
189− if i, ok := any(item).(HasMatchIndexes); ok {
190− i.MatchIndexes(make([]int, 0))
191− }
192− }
193− return cmds
194−}
195−
196−func (f *filterableGroupList[T]) getGroupName(g Group[T]) string {
197− if section, ok := g.Section.(*itemSectionModel); ok {
198− return strings.ToLower(section.title)
199− }
200− return strings.ToLower(g.Section.ID())
201−}
202−
203−func (f *filterableGroupList[T]) setMatchIndexes(item T, indexes []int) {
204− if i, ok := any(item).(HasMatchIndexes); ok {
205− i.MatchIndexes(indexes)
206− }
207−}
208−
209−func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string) []T {
210− if query == "" {
211− // No query, return all items with cleared match indexes
212− var items []T
213− for _, item := range group.Items {
214− f.setMatchIndexes(item, make([]int, 0))
215− items = append(items, item)
216− }
217− return items
218− }
219−
220− name := f.getGroupName(group) + " "
221−
222− names := make([]string, len(group.Items))
223− for i, item := range group.Items {
224− names[i] = strings.ToLower(name + item.FilterValue())
225− }
226−
227− matches := fuzzy.Find(query, names)
228− sort.SliceStable(matches, func(i, j int) bool {
229− return matches[i].Score > matches[j].Score
230− })
231−
232− if len(matches) > 0 {
233− var matchedItems []T
234− for _, match := range matches {
235− item := group.Items[match.Index]
236− var idxs []int
237− for _, idx := range match.MatchedIndexes {
238− // adjusts removing group name highlights
239− if idx < len(name) {
240− continue
241− }
242− idxs = append(idxs, idx-len(name))
243− }
244− f.setMatchIndexes(item, idxs)
245− matchedItems = append(matchedItems, item)
246− }
247− return matchedItems
248− }
249−
250− return []T{}
251−}
252−
253−func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
254− cmds := f.clearItemState()
255− f.selectedItemIdx = -1
256−
257− if query == "" {
258− return f.groupedList.SetGroups(f.groups)
259− }
260−
261− query = strings.ToLower(strings.ReplaceAll(query, " ", ""))
262−
263− var result []Group[T]
264− for _, g := range f.groups {
265− if matches := fuzzy.Find(query, []string{f.getGroupName(g)}); len(matches) > 0 && matches[0].Score > 0 {
266− result = append(result, g)
267− continue
268− }
269− matchedItems := f.filterItemsInGroup(g, query)
270− if len(matchedItems) > 0 {
271− result = append(result, Group[T]{
272− Section: g.Section,
273− Items: matchedItems,
274− })
275− }
276− }
277−
278− cmds = append(cmds, f.groupedList.SetGroups(result))
279− return tea.Batch(cmds...)
280−}
281−
282−func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
283− f.groups = groups
284− return f.groupedList.SetGroups(groups)
285−}
286−
287−func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
288− if f.inputHidden {
289− return nil
290− }
291− return f.input.Cursor()
292−}
293−
294−func (f *filterableGroupList[T]) Blur() tea.Cmd {
295− f.input.Blur()
296− return f.groupedList.Blur()
297−}
298−
299−func (f *filterableGroupList[T]) Focus() tea.Cmd {
300− f.input.Focus()
301− return f.groupedList.Focus()
302−}
303−
304−func (f *filterableGroupList[T]) IsFocused() bool {
305− return f.groupedList.IsFocused()
306−}
307−
308−func (f *filterableGroupList[T]) SetInputWidth(w int) {
309− f.inputWidth = w
310−}
311−
312−func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
313− f.input.Placeholder = ph
314− f.placeholder = ph
315−}
internal/tui/exp/list/filterable_test.godeleted−68
@@ -1,68 +0,0 @@
1−package list
2−
3−import (
4− "fmt"
5− "slices"
6− "testing"
7−
8− "github.com/charmbracelet/x/exp/golden"
9− "github.com/stretchr/testify/assert"
10−)
11−
12−func TestFilterableList(t *testing.T) {
13− t.Parallel()
14− t.Run("should create simple filterable list", func(t *testing.T) {
15− t.Parallel()
16− items := []FilterableItem{}
17− for i := range 5 {
18− item := NewFilterableItem(fmt.Sprintf("Item %d", i))
19− items = append(items, item)
20− }
21− l := NewFilterableList(
22− items,
23− WithFilterListOptions(WithDirectionForward()),
24− ).(*filterableList[FilterableItem])
25−
26− l.SetSize(100, 10)
27− cmd := l.Init()
28− if cmd != nil {
29− cmd()
30− }
31−
32− assert.Equal(t, 0, l.selectedItemIdx)
33− golden.RequireEqual(t, []byte(l.View()))
34− })
35−}
36−
37−func TestUpdateKeyMap(t *testing.T) {
38− t.Parallel()
39− l := NewFilterableList(
40− []FilterableItem{},
41− WithFilterListOptions(WithDirectionForward()),
42− ).(*filterableList[FilterableItem])
43−
44− hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
45− fmt.Println(l.keyMap.Down.Keys())
46− hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
47−
48− hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
49−
50− assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
51− assert.False(t, hasJ, "should not contain j")
52− assert.False(t, hasUpperCaseK, "should also remove upper case K")
53− assert.True(t, hasCtrlJ, "should still have ctrl+j")
54−}
55−
56−type filterableItem struct {
57− *selectableItem
58−}
59−
60−func NewFilterableItem(content string) FilterableItem {
61− return &filterableItem{
62− selectableItem: NewSelectableItem(content).(*selectableItem),
63− }
64−}
65−
66−func (f *filterableItem) FilterValue() string {
67− return f.content
68−}
internal/tui/exp/list/grouped.godeleted−100
@@ -1,100 +0,0 @@
1−package list
2−
3−import (
4− tea "charm.land/bubbletea/v2"
5− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
6− "github.com/charmbracelet/crush/internal/tui/util"
7−)
8−
9−type Group[T Item] struct {
10− Section ItemSection
11− Items []T
12−}
13−type GroupedList[T Item] interface {
14− util.Model
15− layout.Sizeable
16− Items() []Item
17− Groups() []Group[T]
18− SetGroups([]Group[T]) tea.Cmd
19− MoveUp(int) tea.Cmd
20− MoveDown(int) tea.Cmd
21− GoToTop() tea.Cmd
22− GoToBottom() tea.Cmd
23− SelectItemAbove() tea.Cmd
24− SelectItemBelow() tea.Cmd
25− SetSelected(string) tea.Cmd
26− SelectedItem() *T
27−}
28−type groupedList[T Item] struct {
29− *list[Item]
30− groups []Group[T]
31−}
32−
33−func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
34− list := &list[Item]{
35− confOptions: &confOptions{
36− direction: DirectionForward,
37− keyMap: DefaultKeyMap(),
38− focused: true,
39− },
40− items: []Item{},
41− indexMap: make(map[string]int),
42− renderedItems: make(map[string]renderedItem),
43− }
44− for _, opt := range opts {
45− opt(list.confOptions)
46− }
47−
48− return &groupedList[T]{
49− list: list,
50− }
51−}
52−
53−func (g *groupedList[T]) Init() tea.Cmd {
54− g.convertItems()
55− return g.render()
56−}
57−
58−func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
59− u, cmd := l.list.Update(msg)
60− l.list = u.(*list[Item])
61− return l, cmd
62−}
63−
64−func (g *groupedList[T]) SelectedItem() *T {
65− item := g.list.SelectedItem()
66− if item == nil {
67− return nil
68− }
69− dRef := *item
70− c, ok := any(dRef).(T)
71− if !ok {
72− return nil
73− }
74− return &c
75−}
76−
77−func (g *groupedList[T]) convertItems() {
78− var items []Item
79− for _, g := range g.groups {
80− items = append(items, g.Section)
81− for _, g := range g.Items {
82− items = append(items, g)
83− }
84− }
85− g.items = items
86−}
87−
88−func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
89− g.groups = groups
90− g.convertItems()
91− return g.SetItems(g.items)
92−}
93−
94−func (g *groupedList[T]) Groups() []Group[T] {
95− return g.groups
96−}
97−
98−func (g *groupedList[T]) Items() []Item {
99− return g.list.Items()
100−}
internal/tui/exp/list/items.godeleted−399
@@ -1,399 +0,0 @@
1−package list
2−
3−import (
4− "image/color"
5−
6− tea "charm.land/bubbletea/v2"
7− "charm.land/lipgloss/v2"
8− "github.com/charmbracelet/crush/internal/tui/components/core"
9− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
10− "github.com/charmbracelet/crush/internal/tui/styles"
11− "github.com/charmbracelet/crush/internal/tui/util"
12− "github.com/charmbracelet/x/ansi"
13− "github.com/google/uuid"
14− "github.com/rivo/uniseg"
15−)
16−
17−type Indexable interface {
18− SetIndex(int)
19−}
20−
21−type CompletionItem[T any] interface {
22− FilterableItem
23− layout.Focusable
24− layout.Sizeable
25− HasMatchIndexes
26− Value() T
27− Text() string
28−}
29−
30−type completionItemCmp[T any] struct {
31− width int
32− id string
33− text string
34− value T
35− focus bool
36− matchIndexes []int
37− bgColor color.Color
38− shortcut string
39−}
40−
41−type options struct {
42− id string
43− text string
44− bgColor color.Color
45− matchIndexes []int
46− shortcut string
47−}
48−
49−type CompletionItemOption func(*options)
50−
51−func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
52− return func(cmp *options) {
53− cmp.bgColor = c
54− }
55−}
56−
57−func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
58− return func(cmp *options) {
59− cmp.matchIndexes = indexes
60− }
61−}
62−
63−func WithCompletionShortcut(shortcut string) CompletionItemOption {
64− return func(cmp *options) {
65− cmp.shortcut = shortcut
66− }
67−}
68−
69−func WithCompletionID(id string) CompletionItemOption {
70− return func(cmp *options) {
71− cmp.id = id
72− }
73−}
74−
75−func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
76− c := &completionItemCmp[T]{
77− text: text,
78− value: value,
79− }
80− o := &options{}
81−
82− for _, opt := range opts {
83− opt(o)
84− }
85− if o.id == "" {
86− o.id = uuid.NewString()
87− }
88− c.id = o.id
89− c.bgColor = o.bgColor
90− c.matchIndexes = o.matchIndexes
91− c.shortcut = o.shortcut
92− return c
93−}
94−
95−// Init implements CommandItem.
96−func (c *completionItemCmp[T]) Init() tea.Cmd {
97− return nil
98−}
99−
100−// Update implements CommandItem.
101−func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
102− return c, nil
103−}
104−
105−// View implements CommandItem.
106−func (c *completionItemCmp[T]) View() string {
107− t := styles.CurrentTheme()
108−
109− itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
110− innerWidth := c.width - 2 // Account for padding
111−
112− if c.shortcut != "" {
113− innerWidth -= lipgloss.Width(c.shortcut)
114− }
115−
116− titleStyle := t.S().Text.Width(innerWidth)
117− titleMatchStyle := t.S().Text.Underline(true)
118− if c.bgColor != nil {
119− titleStyle = titleStyle.Background(c.bgColor)
120− titleMatchStyle = titleMatchStyle.Background(c.bgColor)
121− itemStyle = itemStyle.Background(c.bgColor)
122− }
123−
124− if c.focus {
125− titleStyle = t.S().TextSelected.Width(innerWidth)
126− titleMatchStyle = t.S().TextSelected.Underline(true)
127− itemStyle = itemStyle.Background(t.Primary)
128− }
129−
130− var truncatedTitle string
131−
132− if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
133− // Smart truncation: ensure the last matching part is visible
134− truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
135− } else {
136− // No matches, use regular truncation
137− truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
138− }
139−
140− text := titleStyle.Render(truncatedTitle)
141− if len(c.matchIndexes) > 0 {
142− var ranges []lipgloss.Range
143− for _, rng := range matchedRanges(c.matchIndexes) {
144− // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
145− // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
146− // so we need to adjust it here:
147− start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
148− ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
149− }
150− text = lipgloss.StyleRanges(text, ranges...)
151− }
152− parts := []string{text}
153− if c.shortcut != "" {
154− // Add the shortcut at the end
155− shortcutStyle := t.S().Muted
156− if c.focus {
157− shortcutStyle = t.S().TextSelected
158− }
159− parts = append(parts, shortcutStyle.Render(c.shortcut))
160− }
161− item := itemStyle.Render(
162− lipgloss.JoinHorizontal(
163− lipgloss.Left,
164− parts...,
165− ),
166− )
167− return item
168−}
169−
170−// Blur implements CommandItem.
171−func (c *completionItemCmp[T]) Blur() tea.Cmd {
172− c.focus = false
173− return nil
174−}
175−
176−// Focus implements CommandItem.
177−func (c *completionItemCmp[T]) Focus() tea.Cmd {
178− c.focus = true
179− return nil
180−}
181−
182−// GetSize implements CommandItem.
183−func (c *completionItemCmp[T]) GetSize() (int, int) {
184− return c.width, 1
185−}
186−
187−// IsFocused implements CommandItem.
188−func (c *completionItemCmp[T]) IsFocused() bool {
189− return c.focus
190−}
191−
192−// SetSize implements CommandItem.
193−func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
194− c.width = width
195− return nil
196−}
197−
198−func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
199− c.matchIndexes = indexes
200−}
201−
202−func (c *completionItemCmp[T]) FilterValue() string {
203− return c.text
204−}
205−
206−func (c *completionItemCmp[T]) Value() T {
207− return c.value
208−}
209−
210−// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
211−func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
212− if width <= 0 {
213− return ""
214− }
215−
216− textLen := ansi.StringWidth(text)
217− if textLen <= width {
218− return text
219− }
220−
221− if len(matchIndexes) == 0 {
222− return ansi.Truncate(text, width, "…")
223− }
224−
225− // Find the last match position
226− lastMatchPos := matchIndexes[len(matchIndexes)-1]
227−
228− // Convert byte position to visual width position
229− lastMatchVisualPos := 0
230− bytePos := 0
231− gr := uniseg.NewGraphemes(text)
232− for bytePos < lastMatchPos && gr.Next() {
233− bytePos += len(gr.Str())
234− lastMatchVisualPos += max(1, gr.Width())
235− }
236−
237− // Calculate how much space we need for the ellipsis
238− ellipsisWidth := 1 // "…" character width
239− availableWidth := width - ellipsisWidth
240−
241− // If the last match is within the available width, truncate from the end
242− if lastMatchVisualPos < availableWidth {
243− return ansi.Truncate(text, width, "…")
244− }
245−
246− // Calculate the start position to ensure the last match is visible
247− // We want to show some context before the last match if possible
248− startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
249−
250− // Convert visual position back to byte position
251− startBytePos := 0
252− currentVisualPos := 0
253− gr = uniseg.NewGraphemes(text)
254− for currentVisualPos < startVisualPos && gr.Next() {
255− startBytePos += len(gr.Str())
256− currentVisualPos += max(1, gr.Width())
257− }
258−
259− // Extract the substring starting from startBytePos
260− truncatedText := text[startBytePos:]
261−
262− // Truncate to fit width with ellipsis
263− truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
264− truncatedText = "…" + truncatedText
265− return truncatedText
266−}
267−
268−func matchedRanges(in []int) [][2]int {
269− if len(in) == 0 {
270− return [][2]int{}
271− }
272− current := [2]int{in[0], in[0]}
273− if len(in) == 1 {
274− return [][2]int{current}
275− }
276− var out [][2]int
277− for i := 1; i < len(in); i++ {
278− if in[i] == current[1]+1 {
279− current[1] = in[i]
280− } else {
281− out = append(out, current)
282− current = [2]int{in[i], in[i]}
283− }
284− }
285− out = append(out, current)
286− return out
287−}
288−
289−func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
290− bytePos, byteStart, byteStop := 0, rng[0], rng[1]
291− pos, start, stop := 0, 0, 0
292− gr := uniseg.NewGraphemes(str)
293− for byteStart > bytePos {
294− if !gr.Next() {
295− break
296− }
297− bytePos += len(gr.Str())
298− pos += max(1, gr.Width())
299− }
300− start = pos
301− for byteStop > bytePos {
302− if !gr.Next() {
303− break
304− }
305− bytePos += len(gr.Str())
306− pos += max(1, gr.Width())
307− }
308− stop = pos
309− return start, stop
310−}
311−
312−// ID implements CompletionItem.
313−func (c *completionItemCmp[T]) ID() string {
314− return c.id
315−}
316−
317−func (c *completionItemCmp[T]) Text() string {
318− return c.text
319−}
320−
321−type ItemSection interface {
322− Item
323− layout.Sizeable
324− Indexable
325− SetInfo(info string)
326− Title() string
327−}
328−type itemSectionModel struct {
329− width int
330− title string
331− inx int
332− id string
333− info string
334−}
335−
336−// ID implements ItemSection.
337−func (m *itemSectionModel) ID() string {
338− return m.id
339−}
340−
341−// Title implements ItemSection.
342−func (m *itemSectionModel) Title() string {
343− return m.title
344−}
345−
346−func NewItemSection(title string) ItemSection {
347− return &itemSectionModel{
348− title: title,
349− inx: -1,
350− id: uuid.NewString(),
351− }
352−}
353−
354−func (m *itemSectionModel) Init() tea.Cmd {
355− return nil
356−}
357−
358−func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
359− return m, nil
360−}
361−
362−func (m *itemSectionModel) View() string {
363− t := styles.CurrentTheme()
364− title := ansi.Truncate(m.title, m.width-2, "…")
365− style := t.S().Base.Padding(1, 1, 0, 1)
366− if m.inx == 0 {
367− style = style.Padding(0, 1, 0, 1)
368− }
369− title = t.S().Muted.Render(title)
370− section := ""
371− if m.info != "" {
372− section = core.SectionWithInfo(title, m.width-2, m.info)
373− } else {
374− section = core.Section(title, m.width-2)
375− }
376−
377− return style.Render(section)
378−}
379−
380−func (m *itemSectionModel) GetSize() (int, int) {
381− return m.width, 1
382−}
383−
384−func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
385− m.width = width
386− return nil
387−}
388−
389−func (m *itemSectionModel) IsSectionHeader() bool {
390− return true
391−}
392−
393−func (m *itemSectionModel) SetInfo(info string) {
394− m.info = info
395−}
396−
397−func (m *itemSectionModel) SetIndex(inx int) {
398− m.inx = inx
399−}
internal/tui/exp/list/keys.godeleted−76
@@ -1,76 +0,0 @@
1−package list
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Down,
9− Up,
10− DownOneItem,
11− UpOneItem,
12− PageDown,
13− PageUp,
14− HalfPageDown,
15− HalfPageUp,
16− Home,
17− End key.Binding
18−}
19−
20−func DefaultKeyMap() KeyMap {
21− return KeyMap{
22− Down: key.NewBinding(
23− key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
24− key.WithHelp("↓", "down"),
25− ),
26− Up: key.NewBinding(
27− key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
28− key.WithHelp("↑", "up"),
29− ),
30− UpOneItem: key.NewBinding(
31− key.WithKeys("shift+up", "K"),
32− key.WithHelp("shift+↑", "up one item"),
33− ),
34− DownOneItem: key.NewBinding(
35− key.WithKeys("shift+down", "J"),
36− key.WithHelp("shift+↓", "down one item"),
37− ),
38− HalfPageDown: key.NewBinding(
39− key.WithKeys("d"),
40− key.WithHelp("d", "half page down"),
41− ),
42− PageDown: key.NewBinding(
43− key.WithKeys("pgdown", " ", "f"),
44− key.WithHelp("f/pgdn", "page down"),
45− ),
46− PageUp: key.NewBinding(
47− key.WithKeys("pgup", "b"),
48− key.WithHelp("b/pgup", "page up"),
49− ),
50− HalfPageUp: key.NewBinding(
51− key.WithKeys("u"),
52− key.WithHelp("u", "half page up"),
53− ),
54− Home: key.NewBinding(
55− key.WithKeys("g", "home"),
56− key.WithHelp("g", "home"),
57− ),
58− End: key.NewBinding(
59− key.WithKeys("G", "end"),
60− key.WithHelp("G", "end"),
61− ),
62− }
63−}
64−
65−func (k KeyMap) KeyBindings() []key.Binding {
66− return []key.Binding{
67− k.Down,
68− k.Up,
69− k.DownOneItem,
70− k.UpOneItem,
71− k.HalfPageDown,
72− k.HalfPageUp,
73− k.Home,
74− k.End,
75− }
76−}
internal/tui/exp/list/list.godeleted−1775
@@ -1,1775 +0,0 @@
1−package list
2−
3−import (
4− "strings"
5− "sync"
6−
7− "charm.land/bubbles/v2/key"
8− tea "charm.land/bubbletea/v2"
9− "charm.land/lipgloss/v2"
10− "github.com/charmbracelet/crush/internal/tui/components/anim"
11− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12− "github.com/charmbracelet/crush/internal/tui/styles"
13− "github.com/charmbracelet/crush/internal/tui/util"
14− uv "github.com/charmbracelet/ultraviolet"
15− "github.com/charmbracelet/x/ansi"
16− "github.com/charmbracelet/x/exp/ordered"
17− "github.com/rivo/uniseg"
18−)
19−
20−const maxGapSize = 100
21−
22−var newlineBuffer = strings.Repeat("\n", maxGapSize)
23−
24−var (
25− specialCharsMap map[string]struct{}
26− specialCharsOnce sync.Once
27−)
28−
29−func getSpecialCharsMap() map[string]struct{} {
30− specialCharsOnce.Do(func() {
31− specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons))
32− for _, icon := range styles.SelectionIgnoreIcons {
33− specialCharsMap[icon] = struct{}{}
34− }
35− })
36− return specialCharsMap
37−}
38−
39−type Item interface {
40− util.Model
41− layout.Sizeable
42− ID() string
43−}
44−
45−type HasAnim interface {
46− Item
47− Spinning() bool
48−}
49−
50−type List[T Item] interface {
51− util.Model
52− layout.Sizeable
53− layout.Focusable
54−
55− MoveUp(int) tea.Cmd
56− MoveDown(int) tea.Cmd
57− GoToTop() tea.Cmd
58− GoToBottom() tea.Cmd
59− SelectItemAbove() tea.Cmd
60− SelectItemBelow() tea.Cmd
61− SetItems([]T) tea.Cmd
62− SetSelected(string) tea.Cmd
63− SelectedItem() *T
64− Items() []T
65− UpdateItem(string, T) tea.Cmd
66− DeleteItem(string) tea.Cmd
67− PrependItem(T) tea.Cmd
68− AppendItem(T) tea.Cmd
69− StartSelection(col, line int)
70− EndSelection(col, line int)
71− SelectionStop()
72− SelectionClear()
73− SelectWord(col, line int)
74− SelectParagraph(col, line int)
75− GetSelectedText(paddingLeft int) string
76− HasSelection() bool
77−}
78−
79−type direction int
80−
81−const (
82− DirectionForward direction = iota
83− DirectionBackward
84−)
85−
86−const (
87− ItemNotFound = -1
88− ViewportDefaultScrollSize = 5
89−)
90−
91−type renderedItem struct {
92− view string
93− height int
94− start int
95− end int
96−}
97−
98−type confOptions struct {
99− width, height int
100− gap int
101− wrap bool
102− keyMap KeyMap
103− direction direction
104− selectedItemIdx int // Index of selected item (-1 if none)
105− selectedItemID string // Temporary storage for WithSelectedItem (resolved in New())
106− focused bool
107− resize bool
108− enableMouse bool
109−}
110−
111−type list[T Item] struct {
112− *confOptions
113−
114− offset int
115−
116− indexMap map[string]int
117− items []T
118− renderedItems map[string]renderedItem
119−
120− rendered string
121− renderedHeight int // cached height of rendered content
122− lineOffsets []int // cached byte offsets for each line (for fast slicing)
123−
124− cachedView string
125− cachedViewOffset int
126− cachedViewDirty bool
127−
128− movingByItem bool
129− prevSelectedItemIdx int // Index of previously selected item (-1 if none)
130− selectionStartCol int
131− selectionStartLine int
132− selectionEndCol int
133− selectionEndLine int
134−
135− selectionActive bool
136−}
137−
138−type ListOption func(*confOptions)
139−
140−// WithSize sets the size of the list.
141−func WithSize(width, height int) ListOption {
142− return func(l *confOptions) {
143− l.width = width
144− l.height = height
145− }
146−}
147−
148−// WithGap sets the gap between items in the list.
149−func WithGap(gap int) ListOption {
150− return func(l *confOptions) {
151− l.gap = gap
152− }
153−}
154−
155−// WithDirectionForward sets the direction to forward
156−func WithDirectionForward() ListOption {
157− return func(l *confOptions) {
158− l.direction = DirectionForward
159− }
160−}
161−
162−// WithDirectionBackward sets the direction to forward
163−func WithDirectionBackward() ListOption {
164− return func(l *confOptions) {
165− l.direction = DirectionBackward
166− }
167−}
168−
169−// WithSelectedItem sets the initially selected item in the list.
170−func WithSelectedItem(id string) ListOption {
171− return func(l *confOptions) {
172− l.selectedItemID = id // Will be resolved to index in New()
173− }
174−}
175−
176−func WithKeyMap(keyMap KeyMap) ListOption {
177− return func(l *confOptions) {
178− l.keyMap = keyMap
179− }
180−}
181−
182−func WithWrapNavigation() ListOption {
183− return func(l *confOptions) {
184− l.wrap = true
185− }
186−}
187−
188−func WithFocus(focus bool) ListOption {
189− return func(l *confOptions) {
190− l.focused = focus
191− }
192−}
193−
194−func WithResizeByList() ListOption {
195− return func(l *confOptions) {
196− l.resize = true
197− }
198−}
199−
200−func WithEnableMouse() ListOption {
201− return func(l *confOptions) {
202− l.enableMouse = true
203− }
204−}
205−
206−func New[T Item](items []T, opts ...ListOption) List[T] {
207− list := &list[T]{
208− confOptions: &confOptions{
209− direction: DirectionForward,
210− keyMap: DefaultKeyMap(),
211− focused: true,
212− selectedItemIdx: -1,
213− },
214− items: items,
215− indexMap: make(map[string]int, len(items)),
216− renderedItems: make(map[string]renderedItem),
217− prevSelectedItemIdx: -1,
218− selectionStartCol: -1,
219− selectionStartLine: -1,
220− selectionEndLine: -1,
221− selectionEndCol: -1,
222− }
223− for _, opt := range opts {
224− opt(list.confOptions)
225− }
226−
227− for inx, item := range items {
228− if i, ok := any(item).(Indexable); ok {
229− i.SetIndex(inx)
230− }
231− list.indexMap[item.ID()] = inx
232− }
233−
234− // Resolve selectedItemID to selectedItemIdx if specified
235− if list.selectedItemID != "" {
236− if idx, ok := list.indexMap[list.selectedItemID]; ok {
237− list.selectedItemIdx = idx
238− }
239− list.selectedItemID = "" // Clear temporary storage
240− }
241−
242− return list
243−}
244−
245−// Init implements List.
246−func (l *list[T]) Init() tea.Cmd {
247− return l.render()
248−}
249−
250−// Update implements List.
251−func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
252− switch msg := msg.(type) {
253− case tea.MouseWheelMsg:
254− if l.enableMouse {
255− return l.handleMouseWheel(msg)
256− }
257− return l, nil
258− case anim.StepMsg:
259− // Fast path: if no items, skip processing
260− if len(l.items) == 0 {
261− return l, nil
262− }
263−
264− // Fast path: check if ANY items are actually spinning before processing
265− if !l.hasSpinningItems() {
266− return l, nil
267− }
268−
269− var cmds []tea.Cmd
270− itemsLen := len(l.items)
271− for i := range itemsLen {
272− if i >= len(l.items) {
273− continue
274− }
275− item := l.items[i]
276− if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
277− updated, cmd := animItem.Update(msg)
278− cmds = append(cmds, cmd)
279− if u, ok := updated.(T); ok {
280− cmds = append(cmds, l.UpdateItem(u.ID(), u))
281− }
282− }
283− }
284− return l, tea.Batch(cmds...)
285− case tea.KeyPressMsg:
286− if l.focused {
287− switch {
288− case key.Matches(msg, l.keyMap.Down):
289− return l, l.MoveDown(ViewportDefaultScrollSize)
290− case key.Matches(msg, l.keyMap.Up):
291− return l, l.MoveUp(ViewportDefaultScrollSize)
292− case key.Matches(msg, l.keyMap.DownOneItem):
293− return l, l.SelectItemBelow()
294− case key.Matches(msg, l.keyMap.UpOneItem):
295− return l, l.SelectItemAbove()
296− case key.Matches(msg, l.keyMap.HalfPageDown):
297− return l, l.MoveDown(l.height / 2)
298− case key.Matches(msg, l.keyMap.HalfPageUp):
299− return l, l.MoveUp(l.height / 2)
300− case key.Matches(msg, l.keyMap.PageDown):
301− return l, l.MoveDown(l.height)
302− case key.Matches(msg, l.keyMap.PageUp):
303− return l, l.MoveUp(l.height)
304− case key.Matches(msg, l.keyMap.End):
305− return l, l.GoToBottom()
306− case key.Matches(msg, l.keyMap.Home):
307− return l, l.GoToTop()
308− }
309− s := l.SelectedItem()
310− if s == nil {
311− return l, nil
312− }
313− item := *s
314− var cmds []tea.Cmd
315− updated, cmd := item.Update(msg)
316− cmds = append(cmds, cmd)
317− if u, ok := updated.(T); ok {
318− cmds = append(cmds, l.UpdateItem(u.ID(), u))
319− }
320− return l, tea.Batch(cmds...)
321− }
322− }
323− return l, nil
324−}
325−
326−func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) {
327− var cmd tea.Cmd
328− switch msg.Button {
329− case tea.MouseWheelDown:
330− cmd = l.MoveDown(ViewportDefaultScrollSize)
331− case tea.MouseWheelUp:
332− cmd = l.MoveUp(ViewportDefaultScrollSize)
333− }
334− return l, cmd
335−}
336−
337−func (l *list[T]) hasSpinningItems() bool {
338− for i := range l.items {
339− item := l.items[i]
340− if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
341− return true
342− }
343− }
344− return false
345−}
346−
347−func (l *list[T]) selectionView(view string, textOnly bool) string {
348− t := styles.CurrentTheme()
349− area := uv.Rect(0, 0, l.width, l.height)
350− scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
351− uv.NewStyledString(view).Draw(scr, area)
352−
353− selArea := l.selectionArea(false)
354− specialChars := getSpecialCharsMap()
355− selStyle := uv.Style{
356− Bg: t.TextSelection.GetBackground(),
357− Fg: t.TextSelection.GetForeground(),
358− }
359−
360− isNonWhitespace := func(r rune) bool {
361− return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
362− }
363−
364− type selectionBounds struct {
365− startX, endX int
366− inSelection bool
367− }
368− lineSelections := make([]selectionBounds, scr.Height())
369−
370− for y := range scr.Height() {
371− bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
372−
373− if y >= selArea.Min.Y && y < selArea.Max.Y {
374− bounds.inSelection = true
375− if selArea.Min.Y == selArea.Max.Y-1 {
376− // Single line selection
377− bounds.startX = selArea.Min.X
378− bounds.endX = selArea.Max.X
379− } else if y == selArea.Min.Y {
380− // First line of multi-line selection
381− bounds.startX = selArea.Min.X
382− bounds.endX = scr.Width()
383− } else if y == selArea.Max.Y-1 {
384− // Last line of multi-line selection
385− bounds.startX = 0
386− bounds.endX = selArea.Max.X
387− } else {
388− // Middle lines
389− bounds.startX = 0
390− bounds.endX = scr.Width()
391− }
392− }
393− lineSelections[y] = bounds
394− }
395−
396− type lineBounds struct {
397− start, end int
398− }
399− lineTextBounds := make([]lineBounds, scr.Height())
400−
401− // First pass: find text bounds for lines that have selections
402− for y := range scr.Height() {
403− bounds := lineBounds{start: -1, end: -1}
404−
405− // Only process lines that might have selections
406− if lineSelections[y].inSelection {
407− for x := range scr.Width() {
408− cell := scr.CellAt(x, y)
409− if cell == nil {
410− continue
411− }
412−
413− cellStr := cell.String()
414− if len(cellStr) == 0 {
415− continue
416− }
417−
418− char := rune(cellStr[0])
419− _, isSpecial := specialChars[cellStr]
420−
421− if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
422− if bounds.start == -1 {
423− bounds.start = x
424− }
425− bounds.end = x + 1 // Position after last character
426− }
427− }
428− }
429− lineTextBounds[y] = bounds
430− }
431−
432− var selectedText strings.Builder
433−
434− // Second pass: apply selection highlighting
435− for y := range scr.Height() {
436− selBounds := lineSelections[y]
437− if !selBounds.inSelection {
438− continue
439− }
440−
441− textBounds := lineTextBounds[y]
442− if textBounds.start < 0 {
443− if textOnly {
444− // We don't want to get rid of all empty lines in text-only mode
445− selectedText.WriteByte('\n')
446− }
447−
448− continue // No text on this line
449− }
450−
451− // Only scan within the intersection of text bounds and selection bounds
452− scanStart := max(textBounds.start, selBounds.startX)
453− scanEnd := min(textBounds.end, selBounds.endX)
454−
455− for x := scanStart; x < scanEnd; x++ {
456− cell := scr.CellAt(x, y)
457− if cell == nil {
458− continue
459− }
460−
461− cellStr := cell.String()
462− if len(cellStr) > 0 {
463− if _, isSpecial := specialChars[cellStr]; isSpecial {
464− continue
465− }
466− if textOnly {
467− // Collect selected text without styles
468− selectedText.WriteString(cell.String())
469− continue
470− }
471−
472− cell = cell.Clone()
473− cell.Style.Bg = selStyle.Bg
474− cell.Style.Fg = selStyle.Fg
475− scr.SetCell(x, y, cell)
476− }
477− }
478−
479− if textOnly {
480− // Make sure we add a newline after each line of selected text
481− selectedText.WriteByte('\n')
482− }
483− }
484−
485− if textOnly {
486− return strings.TrimSpace(selectedText.String())
487− }
488−
489− return scr.Render()
490−}
491−
492−func (l *list[T]) View() string {
493− if l.height <= 0 || l.width <= 0 {
494− return ""
495− }
496−
497− if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
498− return l.cachedView
499− }
500−
501− t := styles.CurrentTheme()
502−
503− start, end := l.viewPosition()
504− viewStart := max(0, start)
505− viewEnd := end
506−
507− if viewStart > viewEnd {
508− return ""
509− }
510−
511− view := l.getLines(viewStart, viewEnd)
512−
513− if l.resize {
514− return view
515− }
516−
517− view = t.S().Base.
518− Height(l.height).
519− Width(l.width).
520− Render(view)
521−
522− if !l.hasSelection() {
523− l.cachedView = view
524− l.cachedViewOffset = l.offset
525− l.cachedViewDirty = false
526− return view
527− }
528−
529− return l.selectionView(view, false)
530−}
531−
532−func (l *list[T]) viewPosition() (int, int) {
533− start, end := 0, 0
534− renderedLines := l.renderedHeight - 1
535− if l.direction == DirectionForward {
536− start = max(0, l.offset)
537− end = min(l.offset+l.height-1, renderedLines)
538− } else {
539− start = max(0, renderedLines-l.offset-l.height+1)
540− end = max(0, renderedLines-l.offset)
541− }
542− start = min(start, end)
543− return start, end
544−}
545−
546−func (l *list[T]) setRendered(rendered string) {
547− l.rendered = rendered
548− l.renderedHeight = lipgloss.Height(rendered)
549− l.cachedViewDirty = true // Mark view cache as dirty
550−
551− if len(rendered) > 0 {
552− l.lineOffsets = make([]int, 0, l.renderedHeight)
553− l.lineOffsets = append(l.lineOffsets, 0)
554−
555− offset := 0
556− for {
557− idx := strings.IndexByte(rendered[offset:], '\n')
558− if idx == -1 {
559− break
560− }
561− offset += idx + 1
562− l.lineOffsets = append(l.lineOffsets, offset)
563− }
564− } else {
565− l.lineOffsets = nil
566− }
567−}
568−
569−func (l *list[T]) getLines(start, end int) string {
570− if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
571− return ""
572− }
573−
574− if end >= len(l.lineOffsets) {
575− end = len(l.lineOffsets) - 1
576− }
577− if start > end {
578− return ""
579− }
580−
581− startOffset := l.lineOffsets[start]
582− var endOffset int
583− if end+1 < len(l.lineOffsets) {
584− endOffset = l.lineOffsets[end+1] - 1
585− } else {
586− endOffset = len(l.rendered)
587− }
588−
589− if startOffset >= len(l.rendered) {
590− return ""
591− }
592− endOffset = min(endOffset, len(l.rendered))
593−
594− return l.rendered[startOffset:endOffset]
595−}
596−
597−// getLine returns a single line from the rendered content using lineOffsets.
598−// This avoids allocating a new string for each line like strings.Split does.
599−func (l *list[T]) getLine(index int) string {
600− if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
601− return ""
602− }
603−
604− startOffset := l.lineOffsets[index]
605− var endOffset int
606− if index+1 < len(l.lineOffsets) {
607− endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
608− } else {
609− endOffset = len(l.rendered)
610− }
611−
612− if startOffset >= len(l.rendered) {
613− return ""
614− }
615− endOffset = min(endOffset, len(l.rendered))
616−
617− return l.rendered[startOffset:endOffset]
618−}
619−
620−// lineCount returns the number of lines in the rendered content.
621−func (l *list[T]) lineCount() int {
622− return len(l.lineOffsets)
623−}
624−
625−func (l *list[T]) recalculateItemPositions() {
626− l.recalculateItemPositionsFrom(0)
627−}
628−
629−func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
630− var currentContentHeight int
631−
632− if startIdx > 0 && startIdx <= len(l.items) {
633− prevItem := l.items[startIdx-1]
634− if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
635− currentContentHeight = rItem.end + 1 + l.gap
636− }
637− }
638−
639− for i := startIdx; i < len(l.items); i++ {
640− item := l.items[i]
641− rItem, ok := l.renderedItems[item.ID()]
642− if !ok {
643− continue
644− }
645− rItem.start = currentContentHeight
646− rItem.end = currentContentHeight + rItem.height - 1
647− l.renderedItems[item.ID()] = rItem
648− currentContentHeight = rItem.end + 1 + l.gap
649− }
650−}
651−
652−func (l *list[T]) render() tea.Cmd {
653− if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
654− return nil
655− }
656− l.setDefaultSelected()
657−
658− var focusChangeCmd tea.Cmd
659− if l.focused {
660− focusChangeCmd = l.focusSelectedItem()
661− } else {
662− focusChangeCmd = l.blurSelectedItem()
663− }
664− if l.rendered != "" {
665− rendered, _ := l.renderIterator(0, false, "")
666− l.setRendered(rendered)
667− if l.direction == DirectionBackward {
668− l.recalculateItemPositions()
669− }
670− if l.focused {
671− l.scrollToSelection()
672− }
673− return focusChangeCmd
674− }
675− rendered, finishIndex := l.renderIterator(0, true, "")
676− l.setRendered(rendered)
677− if l.direction == DirectionBackward {
678− l.recalculateItemPositions()
679− }
680−
681− l.offset = 0
682− rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
683− l.setRendered(rendered)
684− if l.direction == DirectionBackward {
685− l.recalculateItemPositions()
686− }
687− if l.focused {
688− l.scrollToSelection()
689− }
690−
691− return focusChangeCmd
692−}
693−
694−func (l *list[T]) setDefaultSelected() {
695− if l.selectedItemIdx < 0 {
696− if l.direction == DirectionForward {
697− l.selectFirstItem()
698− } else {
699− l.selectLastItem()
700− }
701− }
702−}
703−
704−func (l *list[T]) scrollToSelection() {
705− if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
706− l.selectedItemIdx = -1
707− l.setDefaultSelected()
708− return
709− }
710− item := l.items[l.selectedItemIdx]
711− rItem, ok := l.renderedItems[item.ID()]
712− if !ok {
713− l.selectedItemIdx = -1
714− l.setDefaultSelected()
715− return
716− }
717−
718− start, end := l.viewPosition()
719− if rItem.start <= start && rItem.end >= end {
720− return
721− }
722− if l.movingByItem {
723− if rItem.start >= start && rItem.end <= end {
724− return
725− }
726− defer func() { l.movingByItem = false }()
727− } else {
728− if rItem.start >= start && rItem.start <= end {
729− return
730− }
731− if rItem.end >= start && rItem.end <= end {
732− return
733− }
734− }
735−
736− if rItem.height >= l.height {
737− if l.direction == DirectionForward {
738− l.offset = rItem.start
739− } else {
740− l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
741− }
742− return
743− }
744−
745− renderedLines := l.renderedHeight - 1
746−
747− if rItem.start < start {
748− if l.direction == DirectionForward {
749− l.offset = rItem.start
750− } else {
751− l.offset = max(0, renderedLines-rItem.start-l.height+1)
752− }
753− } else if rItem.end > end {
754− if l.direction == DirectionForward {
755− l.offset = max(0, rItem.end-l.height+1)
756− } else {
757− l.offset = max(0, renderedLines-rItem.end)
758− }
759− }
760−}
761−
762−func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
763− if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
764− return nil
765− }
766− item := l.items[l.selectedItemIdx]
767− rItem, ok := l.renderedItems[item.ID()]
768− if !ok {
769− return nil
770− }
771− start, end := l.viewPosition()
772− // item bigger than the viewport do nothing
773− if rItem.start <= start && rItem.end >= end {
774− return nil
775− }
776− // item already in view do nothing
777− if rItem.start >= start && rItem.end <= end {
778− return nil
779− }
780−
781− itemMiddle := rItem.start + rItem.height/2
782−
783− if itemMiddle < start {
784− // select the first item in the viewport
785− // the item is most likely an item coming after this item
786− inx := l.selectedItemIdx
787− for {
788− inx = l.firstSelectableItemBelow(inx)
789− if inx == ItemNotFound {
790− return nil
791− }
792− if inx < 0 || inx >= len(l.items) {
793− continue
794− }
795−
796− item := l.items[inx]
797− renderedItem, ok := l.renderedItems[item.ID()]
798− if !ok {
799− continue
800− }
801−
802− // If the item is bigger than the viewport, select it
803− if renderedItem.start <= start && renderedItem.end >= end {
804− l.selectedItemIdx = inx
805− return l.render()
806− }
807− // item is in the view
808− if renderedItem.start >= start && renderedItem.start <= end {
809− l.selectedItemIdx = inx
810− return l.render()
811− }
812− }
813− } else if itemMiddle > end {
814− // select the first item in the viewport
815− // the item is most likely an item coming after this item
816− inx := l.selectedItemIdx
817− for {
818− inx = l.firstSelectableItemAbove(inx)
819− if inx == ItemNotFound {
820− return nil
821− }
822− if inx < 0 || inx >= len(l.items) {
823− continue
824− }
825−
826− item := l.items[inx]
827− renderedItem, ok := l.renderedItems[item.ID()]
828− if !ok {
829− continue
830− }
831−
832− // If the item is bigger than the viewport, select it
833− if renderedItem.start <= start && renderedItem.end >= end {
834− l.selectedItemIdx = inx
835− return l.render()
836− }
837− // item is in the view
838− if renderedItem.end >= start && renderedItem.end <= end {
839− l.selectedItemIdx = inx
840− return l.render()
841− }
842− }
843− }
844− return nil
845−}
846−
847−func (l *list[T]) selectFirstItem() {
848− inx := l.firstSelectableItemBelow(-1)
849− if inx != ItemNotFound {
850− l.selectedItemIdx = inx
851− }
852−}
853−
854−func (l *list[T]) selectLastItem() {
855− inx := l.firstSelectableItemAbove(len(l.items))
856− if inx != ItemNotFound {
857− l.selectedItemIdx = inx
858− }
859−}
860−
861−func (l *list[T]) firstSelectableItemAbove(inx int) int {
862− unfocusableCount := 0
863− for i := inx - 1; i >= 0; i-- {
864− if i < 0 || i >= len(l.items) {
865− continue
866− }
867−
868− item := l.items[i]
869− if _, ok := any(item).(layout.Focusable); ok {
870− return i
871− }
872− unfocusableCount++
873− }
874− if unfocusableCount == inx && l.wrap {
875− return l.firstSelectableItemAbove(len(l.items))
876− }
877− return ItemNotFound
878−}
879−
880−func (l *list[T]) firstSelectableItemBelow(inx int) int {
881− unfocusableCount := 0
882− itemsLen := len(l.items)
883− for i := inx + 1; i < itemsLen; i++ {
884− if i < 0 || i >= len(l.items) {
885− continue
886− }
887−
888− item := l.items[i]
889− if _, ok := any(item).(layout.Focusable); ok {
890− return i
891− }
892− unfocusableCount++
893− }
894− if unfocusableCount == itemsLen-inx-1 && l.wrap {
895− return l.firstSelectableItemBelow(-1)
896− }
897− return ItemNotFound
898−}
899−
900−func (l *list[T]) focusSelectedItem() tea.Cmd {
901− if l.selectedItemIdx < 0 || !l.focused {
902− return nil
903− }
904− // Pre-allocate with expected capacity
905− cmds := make([]tea.Cmd, 0, 2)
906−
907− // Blur the previously selected item if it's different
908− if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
909− prevItem := l.items[l.prevSelectedItemIdx]
910− if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
911− cmds = append(cmds, f.Blur())
912− // Mark cache as needing update, but don't delete yet
913− // This allows the render to potentially reuse it
914− delete(l.renderedItems, prevItem.ID())
915− }
916− }
917−
918− // Focus the currently selected item
919− if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
920− item := l.items[l.selectedItemIdx]
921− if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
922− cmds = append(cmds, f.Focus())
923− // Mark for re-render
924− delete(l.renderedItems, item.ID())
925− }
926− }
927−
928− l.prevSelectedItemIdx = l.selectedItemIdx
929− return tea.Batch(cmds...)
930−}
931−
932−func (l *list[T]) blurSelectedItem() tea.Cmd {
933− if l.selectedItemIdx < 0 || l.focused {
934− return nil
935− }
936−
937− // Blur the currently selected item
938− if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
939− item := l.items[l.selectedItemIdx]
940− if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
941− delete(l.renderedItems, item.ID())
942− return f.Blur()
943− }
944− }
945−
946− return nil
947−}
948−
949−// renderFragment holds updated rendered view fragments
950−type renderFragment struct {
951− view string
952− gap int
953−}
954−
955−// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
956−// returns the last index and the rendered content so far
957−// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
958−func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
959− // Pre-allocate fragments with expected capacity
960− itemsLen := len(l.items)
961− expectedFragments := itemsLen - startInx
962− if limitHeight && l.height > 0 {
963− expectedFragments = min(expectedFragments, l.height)
964− }
965− fragments := make([]renderFragment, 0, expectedFragments)
966−
967− currentContentHeight := lipgloss.Height(rendered) - 1
968− finalIndex := itemsLen
969−
970− // first pass: accumulate all fragments to render until the height limit is
971− // reached
972− for i := startInx; i < itemsLen; i++ {
973− if limitHeight && currentContentHeight >= l.height {
974− finalIndex = i
975− break
976− }
977− // cool way to go through the list in both directions
978− inx := i
979−
980− if l.direction != DirectionForward {
981− inx = (itemsLen - 1) - i
982− }
983−
984− if inx < 0 || inx >= len(l.items) {
985− continue
986− }
987−
988− item := l.items[inx]
989−
990− var rItem renderedItem
991− if cache, ok := l.renderedItems[item.ID()]; ok {
992− rItem = cache
993− } else {
994− rItem = l.renderItem(item)
995− rItem.start = currentContentHeight
996− rItem.end = currentContentHeight + rItem.height - 1
997− l.renderedItems[item.ID()] = rItem
998− }
999−
1000− gap := l.gap + 1
1001− if inx == itemsLen-1 {
1002− gap = 0
1003− }
1004−
1005− fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
1006−
1007− currentContentHeight = rItem.end + 1 + l.gap
1008− }
1009−
1010− // second pass: build rendered string efficiently
1011− var b strings.Builder
1012−
1013− // Pre-size the builder to reduce allocations
1014− estimatedSize := len(rendered)
1015− for _, f := range fragments {
1016− estimatedSize += len(f.view) + f.gap
1017− }
1018− b.Grow(estimatedSize)
1019−
1020− if l.direction == DirectionForward {
1021− b.WriteString(rendered)
1022− for i := range fragments {
1023− f := &fragments[i]
1024− b.WriteString(f.view)
1025− // Optimized gap writing using pre-allocated buffer
1026− if f.gap > 0 {
1027− if f.gap <= maxGapSize {
1028− b.WriteString(newlineBuffer[:f.gap])
1029− } else {
1030− b.WriteString(strings.Repeat("\n", f.gap))
1031− }
1032− }
1033− }
1034−
1035− return b.String(), finalIndex
1036− }
1037−
1038− // iterate backwards as fragments are in reversed order
1039− for i := len(fragments) - 1; i >= 0; i-- {
1040− f := &fragments[i]
1041− b.WriteString(f.view)
1042− // Optimized gap writing using pre-allocated buffer
1043− if f.gap > 0 {
1044− if f.gap <= maxGapSize {
1045− b.WriteString(newlineBuffer[:f.gap])
1046− } else {
1047− b.WriteString(strings.Repeat("\n", f.gap))
1048− }
1049− }
1050− }
1051− b.WriteString(rendered)
1052−
1053− return b.String(), finalIndex
1054−}
1055−
1056−func (l *list[T]) renderItem(item Item) renderedItem {
1057− view := item.View()
1058− return renderedItem{
1059− view: view,
1060− height: lipgloss.Height(view),
1061− }
1062−}
1063−
1064−// AppendItem implements List.
1065−func (l *list[T]) AppendItem(item T) tea.Cmd {
1066− // Pre-allocate with expected capacity
1067− cmds := make([]tea.Cmd, 0, 4)
1068− cmd := item.Init()
1069− if cmd != nil {
1070− cmds = append(cmds, cmd)
1071− }
1072−
1073− newIndex := len(l.items)
1074− l.items = append(l.items, item)
1075− l.indexMap[item.ID()] = newIndex
1076−
1077− if l.width > 0 && l.height > 0 {
1078− cmd = item.SetSize(l.width, l.height)
1079− if cmd != nil {
1080− cmds = append(cmds, cmd)
1081− }
1082− }
1083− cmd = l.render()
1084− if cmd != nil {
1085− cmds = append(cmds, cmd)
1086− }
1087− if l.direction == DirectionBackward {
1088− if l.offset == 0 {
1089− cmd = l.GoToBottom()
1090− if cmd != nil {
1091− cmds = append(cmds, cmd)
1092− }
1093− } else {
1094− newItem, ok := l.renderedItems[item.ID()]
1095− if ok {
1096− newLines := newItem.height
1097− if len(l.items) > 1 {
1098− newLines += l.gap
1099− }
1100− l.offset = min(l.renderedHeight-1, l.offset+newLines)
1101− }
1102− }
1103− }
1104− return tea.Sequence(cmds...)
1105−}
1106−
1107−// Blur implements List.
1108−func (l *list[T]) Blur() tea.Cmd {
1109− l.focused = false
1110− return l.render()
1111−}
1112−
1113−// DeleteItem implements List.
1114−func (l *list[T]) DeleteItem(id string) tea.Cmd {
1115− inx, ok := l.indexMap[id]
1116− if !ok {
1117− return nil
1118− }
1119− l.items = append(l.items[:inx], l.items[inx+1:]...)
1120− delete(l.renderedItems, id)
1121− delete(l.indexMap, id)
1122−
1123− // Only update indices for items after the deleted one
1124− itemsLen := len(l.items)
1125− for i := inx; i < itemsLen; i++ {
1126− if i >= 0 && i < len(l.items) {
1127− item := l.items[i]
1128− l.indexMap[item.ID()] = i
1129− }
1130− }
1131−
1132− // Adjust selectedItemIdx if the deleted item was selected or before it
1133− if l.selectedItemIdx == inx {
1134− // Deleted item was selected, select the previous item if possible
1135− if inx > 0 {
1136− l.selectedItemIdx = inx - 1
1137− } else {
1138− l.selectedItemIdx = -1
1139− }
1140− } else if l.selectedItemIdx > inx {
1141− // Selected item is after the deleted one, shift index down
1142− l.selectedItemIdx--
1143− }
1144− cmd := l.render()
1145− if l.rendered != "" {
1146− if l.renderedHeight <= l.height {
1147− l.offset = 0
1148− } else {
1149− maxOffset := l.renderedHeight - l.height
1150− if l.offset > maxOffset {
1151− l.offset = maxOffset
1152− }
1153− }
1154− }
1155− return cmd
1156−}
1157−
1158−// Focus implements List.
1159−func (l *list[T]) Focus() tea.Cmd {
1160− l.focused = true
1161− return l.render()
1162−}
1163−
1164−// GetSize implements List.
1165−func (l *list[T]) GetSize() (int, int) {
1166− return l.width, l.height
1167−}
1168−
1169−// GoToBottom implements List.
1170−func (l *list[T]) GoToBottom() tea.Cmd {
1171− l.offset = 0
1172− l.selectedItemIdx = -1
1173− l.direction = DirectionBackward
1174− return l.render()
1175−}
1176−
1177−// GoToTop implements List.
1178−func (l *list[T]) GoToTop() tea.Cmd {
1179− l.offset = 0
1180− l.selectedItemIdx = -1
1181− l.direction = DirectionForward
1182− return l.render()
1183−}
1184−
1185−// IsFocused implements List.
1186−func (l *list[T]) IsFocused() bool {
1187− return l.focused
1188−}
1189−
1190−// Items implements List.
1191−func (l *list[T]) Items() []T {
1192− itemsLen := len(l.items)
1193− result := make([]T, 0, itemsLen)
1194− for i := range itemsLen {
1195− if i >= 0 && i < len(l.items) {
1196− item := l.items[i]
1197− result = append(result, item)
1198− }
1199− }
1200− return result
1201−}
1202−
1203−func (l *list[T]) incrementOffset(n int) {
1204− // no need for offset
1205− if l.renderedHeight <= l.height {
1206− return
1207− }
1208− maxOffset := l.renderedHeight - l.height
1209− n = min(n, maxOffset-l.offset)
1210− if n <= 0 {
1211− return
1212− }
1213− l.offset += n
1214− l.cachedViewDirty = true
1215−}
1216−
1217−func (l *list[T]) decrementOffset(n int) {
1218− n = min(n, l.offset)
1219− if n <= 0 {
1220− return
1221− }
1222− l.offset -= n
1223− if l.offset < 0 {
1224− l.offset = 0
1225− }
1226− l.cachedViewDirty = true
1227−}
1228−
1229−// MoveDown implements List.
1230−func (l *list[T]) MoveDown(n int) tea.Cmd {
1231− oldOffset := l.offset
1232− if l.direction == DirectionForward {
1233− l.incrementOffset(n)
1234− } else {
1235− l.decrementOffset(n)
1236− }
1237−
1238− if oldOffset == l.offset {
1239− // no change in offset, so no need to change selection
1240− return nil
1241− }
1242− // if we are not actively selecting move the whole selection down
1243− if l.hasSelection() && !l.selectionActive {
1244− if l.selectionStartLine < l.selectionEndLine {
1245− l.selectionStartLine -= n
1246− l.selectionEndLine -= n
1247− } else {
1248− l.selectionStartLine -= n
1249− l.selectionEndLine -= n
1250− }
1251− }
1252− if l.selectionActive {
1253− if l.selectionStartLine < l.selectionEndLine {
1254− l.selectionStartLine -= n
1255− } else {
1256− l.selectionEndLine -= n
1257− }
1258− }
1259− return l.changeSelectionWhenScrolling()
1260−}
1261−
1262−// MoveUp implements List.
1263−func (l *list[T]) MoveUp(n int) tea.Cmd {
1264− oldOffset := l.offset
1265− if l.direction == DirectionForward {
1266− l.decrementOffset(n)
1267− } else {
1268− l.incrementOffset(n)
1269− }
1270−
1271− if oldOffset == l.offset {
1272− // no change in offset, so no need to change selection
1273− return nil
1274− }
1275−
1276− if l.hasSelection() && !l.selectionActive {
1277− if l.selectionStartLine > l.selectionEndLine {
1278− l.selectionStartLine += n
1279− l.selectionEndLine += n
1280− } else {
1281− l.selectionStartLine += n
1282− l.selectionEndLine += n
1283− }
1284− }
1285− if l.selectionActive {
1286− if l.selectionStartLine > l.selectionEndLine {
1287− l.selectionStartLine += n
1288− } else {
1289− l.selectionEndLine += n
1290− }
1291− }
1292− return l.changeSelectionWhenScrolling()
1293−}
1294−
1295−// PrependItem implements List.
1296−func (l *list[T]) PrependItem(item T) tea.Cmd {
1297− // Pre-allocate with expected capacity
1298− cmds := make([]tea.Cmd, 0, 4)
1299− cmds = append(cmds, item.Init())
1300−
1301− l.items = append([]T{item}, l.items...)
1302−
1303− // Shift selectedItemIdx since all items moved down by 1
1304− if l.selectedItemIdx >= 0 {
1305− l.selectedItemIdx++
1306− }
1307−
1308− // Update index map incrementally: shift all existing indices up by 1
1309− // This is more efficient than rebuilding from scratch
1310− newIndexMap := make(map[string]int, len(l.indexMap)+1)
1311− for id, idx := range l.indexMap {
1312− newIndexMap[id] = idx + 1 // All existing items shift down by 1
1313− }
1314− newIndexMap[item.ID()] = 0 // New item is at index 0
1315− l.indexMap = newIndexMap
1316−
1317− if l.width > 0 && l.height > 0 {
1318− cmds = append(cmds, item.SetSize(l.width, l.height))
1319− }
1320− cmds = append(cmds, l.render())
1321− if l.direction == DirectionForward {
1322− if l.offset == 0 {
1323− cmd := l.GoToTop()
1324− if cmd != nil {
1325− cmds = append(cmds, cmd)
1326− }
1327− } else {
1328− newItem, ok := l.renderedItems[item.ID()]
1329− if ok {
1330− newLines := newItem.height
1331− if len(l.items) > 1 {
1332− newLines += l.gap
1333− }
1334− l.offset = min(l.renderedHeight-1, l.offset+newLines)
1335− }
1336− }
1337− }
1338− return tea.Batch(cmds...)
1339−}
1340−
1341−// SelectItemAbove implements List.
1342−func (l *list[T]) SelectItemAbove() tea.Cmd {
1343− if l.selectedItemIdx < 0 {
1344− return nil
1345− }
1346−
1347− newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
1348− if newIndex == ItemNotFound {
1349− // no item above
1350− return nil
1351− }
1352− // Pre-allocate with expected capacity
1353− cmds := make([]tea.Cmd, 0, 2)
1354− if newIndex > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 {
1355− // this means there is a section above and not showing on the top, move to the top
1356− newIndex = l.selectedItemIdx
1357− cmd := l.GoToTop()
1358− if cmd != nil {
1359− cmds = append(cmds, cmd)
1360− }
1361− }
1362− if newIndex == 1 {
1363− peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1364− if peakAboveIndex == ItemNotFound {
1365− // this means there is a section above move to the top
1366− cmd := l.GoToTop()
1367− if cmd != nil {
1368− cmds = append(cmds, cmd)
1369− }
1370− }
1371− }
1372− if newIndex < 0 || newIndex >= len(l.items) {
1373− return nil
1374− }
1375− l.prevSelectedItemIdx = l.selectedItemIdx
1376− l.selectedItemIdx = newIndex
1377− l.movingByItem = true
1378− renderCmd := l.render()
1379− if renderCmd != nil {
1380− cmds = append(cmds, renderCmd)
1381− }
1382− return tea.Sequence(cmds...)
1383−}
1384−
1385−// SelectItemBelow implements List.
1386−func (l *list[T]) SelectItemBelow() tea.Cmd {
1387− if l.selectedItemIdx < 0 {
1388− return nil
1389− }
1390−
1391− newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
1392− if newIndex == ItemNotFound {
1393− // no item below
1394− return nil
1395− }
1396− if newIndex < 0 || newIndex >= len(l.items) {
1397− return nil
1398− }
1399− if newIndex < l.selectedItemIdx {
1400− // reset offset when wrap to the top to show the top section if it exists
1401− l.offset = 0
1402− }
1403− l.prevSelectedItemIdx = l.selectedItemIdx
1404− l.selectedItemIdx = newIndex
1405− l.movingByItem = true
1406− return l.render()
1407−}
1408−
1409−// SelectedItem implements List.
1410−func (l *list[T]) SelectedItem() *T {
1411− if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
1412− return nil
1413− }
1414− item := l.items[l.selectedItemIdx]
1415− return &item
1416−}
1417−
1418−// SetItems implements List.
1419−func (l *list[T]) SetItems(items []T) tea.Cmd {
1420− l.items = items
1421− var cmds []tea.Cmd
1422− for inx, item := range items {
1423− if i, ok := any(item).(Indexable); ok {
1424− i.SetIndex(inx)
1425− }
1426− cmds = append(cmds, item.Init())
1427− }
1428− cmds = append(cmds, l.reset(""))
1429− return tea.Batch(cmds...)
1430−}
1431−
1432−// SetSelected implements List.
1433−func (l *list[T]) SetSelected(id string) tea.Cmd {
1434− l.prevSelectedItemIdx = l.selectedItemIdx
1435− if idx, ok := l.indexMap[id]; ok {
1436− l.selectedItemIdx = idx
1437− } else {
1438− l.selectedItemIdx = -1
1439− }
1440− return l.render()
1441−}
1442−
1443−func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1444− var cmds []tea.Cmd
1445− l.rendered = ""
1446− l.renderedHeight = 0
1447− l.offset = 0
1448− l.indexMap = make(map[string]int)
1449− l.renderedItems = make(map[string]renderedItem)
1450− itemsLen := len(l.items)
1451− for i := range itemsLen {
1452− if i < 0 || i >= len(l.items) {
1453− continue
1454− }
1455−
1456− item := l.items[i]
1457− l.indexMap[item.ID()] = i
1458− if l.width > 0 && l.height > 0 {
1459− cmds = append(cmds, item.SetSize(l.width, l.height))
1460− }
1461− }
1462− // Convert selectedItemID to index after rebuilding indexMap
1463− if selectedItemID != "" {
1464− if idx, ok := l.indexMap[selectedItemID]; ok {
1465− l.selectedItemIdx = idx
1466− } else {
1467− l.selectedItemIdx = -1
1468− }
1469− } else {
1470− l.selectedItemIdx = -1
1471− }
1472− cmds = append(cmds, l.render())
1473− return tea.Batch(cmds...)
1474−}
1475−
1476−// SetSize implements List.
1477−func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1478− oldWidth := l.width
1479− oldHeight := l.height
1480− l.width = width
1481− l.height = height
1482− // Invalidate cache if height changed
1483− if oldHeight != height {
1484− l.cachedViewDirty = true
1485− }
1486− if oldWidth != width {
1487− // Get current selected item ID before reset
1488− selectedID := ""
1489− if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
1490− item := l.items[l.selectedItemIdx]
1491− selectedID = item.ID()
1492− }
1493− cmd := l.reset(selectedID)
1494− return cmd
1495− }
1496− return nil
1497−}
1498−
1499−// UpdateItem implements List.
1500−func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1501− // Pre-allocate with expected capacity
1502− cmds := make([]tea.Cmd, 0, 1)
1503− if inx, ok := l.indexMap[id]; ok {
1504− l.items[inx] = item
1505− oldItem, hasOldItem := l.renderedItems[id]
1506− oldPosition := l.offset
1507− if l.direction == DirectionBackward {
1508− oldPosition = (l.renderedHeight - 1) - l.offset
1509− }
1510−
1511− delete(l.renderedItems, id)
1512− cmd := l.render()
1513−
1514− // need to check for nil because of sequence not handling nil
1515− if cmd != nil {
1516− cmds = append(cmds, cmd)
1517− }
1518− if hasOldItem && l.direction == DirectionBackward {
1519− // if we are the last item and there is no offset
1520− // make sure to go to the bottom
1521− if oldPosition < oldItem.end {
1522− newItem, ok := l.renderedItems[item.ID()]
1523− if ok {
1524− newLines := newItem.height - oldItem.height
1525− l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
1526− }
1527− }
1528− } else if hasOldItem && l.offset > oldItem.start {
1529− newItem, ok := l.renderedItems[item.ID()]
1530− if ok {
1531− newLines := newItem.height - oldItem.height
1532− l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
1533− }
1534− }
1535− }
1536− return tea.Sequence(cmds...)
1537−}
1538−
1539−func (l *list[T]) hasSelection() bool {
1540− return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1541−}
1542−
1543−// StartSelection implements List.
1544−func (l *list[T]) StartSelection(col, line int) {
1545− l.selectionStartCol = col
1546− l.selectionStartLine = line
1547− l.selectionEndCol = col
1548− l.selectionEndLine = line
1549− l.selectionActive = true
1550−}
1551−
1552−// EndSelection implements List.
1553−func (l *list[T]) EndSelection(col, line int) {
1554− if !l.selectionActive {
1555− return
1556− }
1557− l.selectionEndCol = col
1558− l.selectionEndLine = line
1559−}
1560−
1561−func (l *list[T]) SelectionStop() {
1562− l.selectionActive = false
1563−}
1564−
1565−func (l *list[T]) SelectionClear() {
1566− l.selectionStartCol = -1
1567− l.selectionStartLine = -1
1568− l.selectionEndCol = -1
1569− l.selectionEndLine = -1
1570− l.selectionActive = false
1571−}
1572−
1573−func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1574− numLines := l.lineCount()
1575−
1576− if l.direction == DirectionBackward && numLines > l.height {
1577− line = ((numLines - 1) - l.height) + line + 1
1578− }
1579−
1580− if l.offset > 0 {
1581− if l.direction == DirectionBackward {
1582− line -= l.offset
1583− } else {
1584− line += l.offset
1585− }
1586− }
1587−
1588− if line < 0 || line >= numLines {
1589− return 0, 0
1590− }
1591−
1592− currentLine := ansi.Strip(l.getLine(line))
1593− gr := uniseg.NewGraphemes(currentLine)
1594− startCol = -1
1595− upTo := col
1596− for gr.Next() {
1597− if gr.IsWordBoundary() && upTo > 0 {
1598− startCol = col - upTo + 1
1599− } else if gr.IsWordBoundary() && upTo < 0 {
1600− endCol = col - upTo + 1
1601− break
1602− }
1603− if upTo == 0 && gr.Str() == " " {
1604− return 0, 0
1605− }
1606− upTo -= 1
1607− }
1608− if startCol == -1 {
1609− return 0, 0
1610− }
1611− return startCol, endCol
1612−}
1613−
1614−func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1615− // Helper function to get a line with ANSI stripped and icons replaced
1616− getCleanLine := func(index int) string {
1617− rawLine := l.getLine(index)
1618− cleanLine := ansi.Strip(rawLine)
1619− for _, icon := range styles.SelectionIgnoreIcons {
1620− cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
1621− }
1622− return cleanLine
1623− }
1624−
1625− numLines := l.lineCount()
1626− if l.direction == DirectionBackward && numLines > l.height {
1627− line = (numLines - 1) - l.height + line + 1
1628− }
1629−
1630− if l.offset > 0 {
1631− if l.direction == DirectionBackward {
1632− line -= l.offset
1633− } else {
1634− line += l.offset
1635− }
1636− }
1637−
1638− // Ensure line is within bounds
1639− if line < 0 || line >= numLines {
1640− return 0, 0, false
1641− }
1642−
1643− if strings.TrimSpace(getCleanLine(line)) == "" {
1644− return 0, 0, false
1645− }
1646−
1647− // Find start of paragraph (search backwards for empty line or start of text)
1648− startLine = line
1649− for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
1650− startLine--
1651− }
1652−
1653− // Find end of paragraph (search forwards for empty line or end of text)
1654− endLine = line
1655− for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
1656− endLine++
1657− }
1658−
1659− // revert the line numbers if we are in backward direction
1660− if l.direction == DirectionBackward && numLines > l.height {
1661− startLine = startLine - (numLines - 1) + l.height - 1
1662− endLine = endLine - (numLines - 1) + l.height - 1
1663− }
1664− if l.offset > 0 {
1665− if l.direction == DirectionBackward {
1666− startLine += l.offset
1667− endLine += l.offset
1668− } else {
1669− startLine -= l.offset
1670− endLine -= l.offset
1671− }
1672− }
1673− return startLine, endLine, true
1674−}
1675−
1676−// SelectWord selects the word at the given position.
1677−func (l *list[T]) SelectWord(col, line int) {
1678− startCol, endCol := l.findWordBoundaries(col, line)
1679− l.selectionStartCol = startCol
1680− l.selectionStartLine = line
1681− l.selectionEndCol = endCol
1682− l.selectionEndLine = line
1683− l.selectionActive = false // Not actively selecting, just selected
1684−}
1685−
1686−// SelectParagraph selects the paragraph at the given position.
1687−func (l *list[T]) SelectParagraph(col, line int) {
1688− startLine, endLine, found := l.findParagraphBoundaries(line)
1689− if !found {
1690− return
1691− }
1692− l.selectionStartCol = 0
1693− l.selectionStartLine = startLine
1694− l.selectionEndCol = l.width - 1
1695− l.selectionEndLine = endLine
1696− l.selectionActive = false // Not actively selecting, just selected
1697−}
1698−
1699−// HasSelection returns whether there is an active selection.
1700−func (l *list[T]) HasSelection() bool {
1701− return l.hasSelection()
1702−}
1703−
1704−func (l *list[T]) selectionArea(absolute bool) uv.Rectangle {
1705− var startY int
1706− if absolute {
1707− startY, _ = l.viewPosition()
1708− }
1709− selArea := uv.Rectangle{
1710− Min: uv.Pos(l.selectionStartCol, l.selectionStartLine+startY),
1711− Max: uv.Pos(l.selectionEndCol, l.selectionEndLine+startY),
1712− }
1713− selArea = selArea.Canon()
1714− selArea.Max.Y++ // make max Y exclusive
1715− return selArea
1716−}
1717−
1718−// GetSelectedText returns the currently selected text.
1719−func (l *list[T]) GetSelectedText(paddingLeft int) string {
1720− if !l.hasSelection() {
1721− return ""
1722− }
1723−
1724− selArea := l.selectionArea(true)
1725− if selArea.Empty() {
1726− return ""
1727− }
1728−
1729− selectionHeight := selArea.Dy()
1730−
1731− tempBuf := uv.NewScreenBuffer(l.width, selectionHeight)
1732− tempBufArea := tempBuf.Bounds()
1733− renderedLines := l.getLines(selArea.Min.Y, selArea.Max.Y)
1734− styled := uv.NewStyledString(renderedLines)
1735− styled.Draw(tempBuf, tempBufArea)
1736−
1737− // XXX: Left padding assumes the list component is rendered with absolute
1738− // positioning. The chat component has a left margin of 1 and items in the
1739− // list have a border of 1 plus a padding of 1. The paddingLeft parameter
1740− // assumes this total left padding of 3 and we should fix that.
1741− leftBorder := paddingLeft - 1
1742−
1743− var b strings.Builder
1744− for y := tempBufArea.Min.Y; y < tempBufArea.Max.Y; y++ {
1745− var pending strings.Builder
1746− for x := tempBufArea.Min.X + leftBorder; x < tempBufArea.Max.X; {
1747− cell := tempBuf.CellAt(x, y)
1748− if cell == nil || cell.IsZero() {
1749− x++
1750− continue
1751− }
1752− if y == 0 && x < selArea.Min.X {
1753− x++
1754− continue
1755− }
1756− if y == selectionHeight-1 && x > selArea.Max.X-1 {
1757− break
1758− }
1759− if cell.Width == 1 && cell.Content == " " {
1760− pending.WriteString(cell.Content)
1761− x++
1762− continue
1763− }
1764− b.WriteString(pending.String())
1765− pending.Reset()
1766− b.WriteString(cell.Content)
1767− x += cell.Width
1768− }
1769− if y < tempBufArea.Max.Y-1 {
1770− b.WriteByte('\n')
1771− }
1772− }
1773−
1774− return b.String()
1775−}
internal/tui/exp/list/list_test.godeleted−653
@@ -1,653 +0,0 @@
1−package list
2−
3−import (
4− "fmt"
5− "strings"
6− "testing"
7−
8− tea "charm.land/bubbletea/v2"
9− "charm.land/lipgloss/v2"
10− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
11− "github.com/charmbracelet/crush/internal/tui/util"
12− "github.com/charmbracelet/x/exp/golden"
13− "github.com/google/uuid"
14− "github.com/stretchr/testify/assert"
15− "github.com/stretchr/testify/require"
16−)
17−
18−func TestList(t *testing.T) {
19− t.Parallel()
20− t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
21− t.Parallel()
22− items := []Item{}
23− for i := range 5 {
24− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
25− items = append(items, item)
26− }
27− l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
28− execCmd(l, l.Init())
29−
30− // should select the last item
31− assert.Equal(t, 0, l.selectedItemIdx)
32− assert.Equal(t, 0, l.offset)
33− require.Equal(t, 5, len(l.indexMap))
34− require.Equal(t, 5, len(l.items))
35− require.Equal(t, 5, len(l.renderedItems))
36− assert.Equal(t, 5, lipgloss.Height(l.rendered))
37− assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
38− start, end := l.viewPosition()
39− assert.Equal(t, 0, start)
40− assert.Equal(t, 4, end)
41− for i := range 5 {
42− item, ok := l.renderedItems[items[i].ID()]
43− require.True(t, ok)
44− assert.Equal(t, i, item.start)
45− assert.Equal(t, i, item.end)
46− }
47−
48− golden.RequireEqual(t, []byte(l.View()))
49− })
50− t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
51− t.Parallel()
52− items := []Item{}
53− for i := range 5 {
54− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
55− items = append(items, item)
56− }
57− l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
58− execCmd(l, l.Init())
59−
60− // should select the last item
61− assert.Equal(t, 4, l.selectedItemIdx)
62− assert.Equal(t, 0, l.offset)
63− require.Equal(t, 5, len(l.indexMap))
64− require.Equal(t, 5, len(l.items))
65− require.Equal(t, 5, len(l.renderedItems))
66− assert.Equal(t, 5, lipgloss.Height(l.rendered))
67− assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
68− start, end := l.viewPosition()
69− assert.Equal(t, 0, start)
70− assert.Equal(t, 4, end)
71− for i := range 5 {
72− item, ok := l.renderedItems[items[i].ID()]
73− require.True(t, ok)
74− assert.Equal(t, i, item.start)
75− assert.Equal(t, i, item.end)
76− }
77−
78− golden.RequireEqual(t, []byte(l.View()))
79− })
80−
81− t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
82− t.Parallel()
83− items := []Item{}
84− for i := range 30 {
85− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
86− items = append(items, item)
87− }
88− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
89− execCmd(l, l.Init())
90−
91− // should select the last item
92− assert.Equal(t, 0, l.selectedItemIdx)
93− assert.Equal(t, 0, l.offset)
94− require.Equal(t, 30, len(l.indexMap))
95− require.Equal(t, 30, len(l.items))
96− require.Equal(t, 30, len(l.renderedItems))
97− assert.Equal(t, 30, lipgloss.Height(l.rendered))
98− assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
99− start, end := l.viewPosition()
100− assert.Equal(t, 0, start)
101− assert.Equal(t, 9, end)
102− for i := range 30 {
103− item, ok := l.renderedItems[items[i].ID()]
104− require.True(t, ok)
105− assert.Equal(t, i, item.start)
106− assert.Equal(t, i, item.end)
107− }
108−
109− golden.RequireEqual(t, []byte(l.View()))
110− })
111− t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
112− t.Parallel()
113− items := []Item{}
114− for i := range 30 {
115− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
116− items = append(items, item)
117− }
118− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
119− execCmd(l, l.Init())
120−
121− // should select the last item
122− assert.Equal(t, 29, l.selectedItemIdx)
123− assert.Equal(t, 0, l.offset)
124− require.Equal(t, 30, len(l.indexMap))
125− require.Equal(t, 30, len(l.items))
126− require.Equal(t, 30, len(l.renderedItems))
127− assert.Equal(t, 30, lipgloss.Height(l.rendered))
128− assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
129− start, end := l.viewPosition()
130− assert.Equal(t, 20, start)
131− assert.Equal(t, 29, end)
132− for i := range 30 {
133− item, ok := l.renderedItems[items[i].ID()]
134− require.True(t, ok)
135− assert.Equal(t, i, item.start)
136− assert.Equal(t, i, item.end)
137− }
138−
139− golden.RequireEqual(t, []byte(l.View()))
140− })
141−
142− t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
143− t.Parallel()
144− items := []Item{}
145− for i := range 30 {
146− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
147− content = strings.TrimSuffix(content, "\n")
148− item := NewSelectableItem(content)
149− items = append(items, item)
150− }
151− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
152− execCmd(l, l.Init())
153−
154− // should select the last item
155− assert.Equal(t, 0, l.selectedItemIdx)
156− assert.Equal(t, 0, l.offset)
157− require.Equal(t, 30, len(l.indexMap))
158− require.Equal(t, 30, len(l.items))
159− require.Equal(t, 30, len(l.renderedItems))
160− expectedLines := 0
161− for i := range 30 {
162− expectedLines += (i + 1) * 1
163− }
164− assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
165− assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
166− start, end := l.viewPosition()
167− assert.Equal(t, 0, start)
168− assert.Equal(t, 9, end)
169− currentPosition := 0
170− for i := range 30 {
171− rItem, ok := l.renderedItems[items[i].ID()]
172− require.True(t, ok)
173− assert.Equal(t, currentPosition, rItem.start)
174− assert.Equal(t, currentPosition+i, rItem.end)
175− currentPosition += i + 1
176− }
177−
178− golden.RequireEqual(t, []byte(l.View()))
179− })
180− t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
181− t.Parallel()
182− items := []Item{}
183− for i := range 30 {
184− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
185− content = strings.TrimSuffix(content, "\n")
186− item := NewSelectableItem(content)
187− items = append(items, item)
188− }
189− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
190− execCmd(l, l.Init())
191−
192− // should select the last item
193− assert.Equal(t, 29, l.selectedItemIdx)
194− assert.Equal(t, 0, l.offset)
195− require.Equal(t, 30, len(l.indexMap))
196− require.Equal(t, 30, len(l.items))
197− require.Equal(t, 30, len(l.renderedItems))
198− expectedLines := 0
199− for i := range 30 {
200− expectedLines += (i + 1) * 1
201− }
202− assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
203− assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
204− start, end := l.viewPosition()
205− assert.Equal(t, expectedLines-10, start)
206− assert.Equal(t, expectedLines-1, end)
207− currentPosition := 0
208− for i := range 30 {
209− rItem, ok := l.renderedItems[items[i].ID()]
210− require.True(t, ok)
211− assert.Equal(t, currentPosition, rItem.start)
212− assert.Equal(t, currentPosition+i, rItem.end)
213− currentPosition += i + 1
214− }
215−
216− golden.RequireEqual(t, []byte(l.View()))
217− })
218−
219− t.Run("should go to selected item at the beginning", func(t *testing.T) {
220− t.Parallel()
221− items := []Item{}
222− for i := range 30 {
223− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
224− content = strings.TrimSuffix(content, "\n")
225− item := NewSelectableItem(content)
226− items = append(items, item)
227− }
228− l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
229− execCmd(l, l.Init())
230−
231− // should select the last item
232− assert.Equal(t, 10, l.selectedItemIdx)
233−
234− golden.RequireEqual(t, []byte(l.View()))
235− })
236−
237− t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
238− t.Parallel()
239− items := []Item{}
240− for i := range 30 {
241− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
242− content = strings.TrimSuffix(content, "\n")
243− item := NewSelectableItem(content)
244− items = append(items, item)
245− }
246− l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
247− execCmd(l, l.Init())
248−
249− // should select the last item
250− assert.Equal(t, 10, l.selectedItemIdx)
251−
252− golden.RequireEqual(t, []byte(l.View()))
253− })
254−}
255−
256−func TestListMovement(t *testing.T) {
257− t.Parallel()
258− t.Run("should move viewport up", func(t *testing.T) {
259− t.Parallel()
260− items := []Item{}
261− for i := range 30 {
262− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
263− content = strings.TrimSuffix(content, "\n")
264− item := NewSelectableItem(content)
265− items = append(items, item)
266− }
267− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
268− execCmd(l, l.Init())
269−
270− execCmd(l, l.MoveUp(25))
271−
272− assert.Equal(t, 25, l.offset)
273− golden.RequireEqual(t, []byte(l.View()))
274− })
275− t.Run("should move viewport up and down", func(t *testing.T) {
276− t.Parallel()
277− items := []Item{}
278− for i := range 30 {
279− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
280− content = strings.TrimSuffix(content, "\n")
281− item := NewSelectableItem(content)
282− items = append(items, item)
283− }
284− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
285− execCmd(l, l.Init())
286−
287− execCmd(l, l.MoveUp(25))
288− execCmd(l, l.MoveDown(25))
289−
290− assert.Equal(t, 0, l.offset)
291− golden.RequireEqual(t, []byte(l.View()))
292− })
293−
294− t.Run("should move viewport down", func(t *testing.T) {
295− t.Parallel()
296− items := []Item{}
297− for i := range 30 {
298− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
299− content = strings.TrimSuffix(content, "\n")
300− item := NewSelectableItem(content)
301− items = append(items, item)
302− }
303− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
304− execCmd(l, l.Init())
305−
306− execCmd(l, l.MoveDown(25))
307−
308− assert.Equal(t, 25, l.offset)
309− golden.RequireEqual(t, []byte(l.View()))
310− })
311− t.Run("should move viewport down and up", func(t *testing.T) {
312− t.Parallel()
313− items := []Item{}
314− for i := range 30 {
315− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
316− content = strings.TrimSuffix(content, "\n")
317− item := NewSelectableItem(content)
318− items = append(items, item)
319− }
320− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
321− execCmd(l, l.Init())
322−
323− execCmd(l, l.MoveDown(25))
324− execCmd(l, l.MoveUp(25))
325−
326− assert.Equal(t, 0, l.offset)
327− golden.RequireEqual(t, []byte(l.View()))
328− })
329−
330− t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
331− t.Parallel()
332− items := []Item{}
333− for i := range 30 {
334− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
335− content = strings.TrimSuffix(content, "\n")
336− item := NewSelectableItem(content)
337− items = append(items, item)
338− }
339− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
340− execCmd(l, l.Init())
341− execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
342−
343− assert.Equal(t, 0, l.offset)
344− golden.RequireEqual(t, []byte(l.View()))
345− })
346−
347− 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) {
348− t.Parallel()
349− items := []Item{}
350− for i := range 30 {
351− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
352− items = append(items, item)
353− }
354− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
355− execCmd(l, l.Init())
356−
357− execCmd(l, l.MoveUp(2))
358− viewBefore := l.View()
359− execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
360− viewAfter := l.View()
361− assert.Equal(t, viewBefore, viewAfter)
362− assert.Equal(t, 5, l.offset)
363− assert.Equal(t, 33, lipgloss.Height(l.rendered))
364− golden.RequireEqual(t, []byte(l.View()))
365− })
366− 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) {
367− t.Parallel()
368− items := []Item{}
369− for i := range 30 {
370− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
371− items = append(items, item)
372− }
373− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
374− execCmd(l, l.Init())
375−
376− execCmd(l, l.MoveUp(2))
377− viewBefore := l.View()
378− item := items[29]
379− execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
380− viewAfter := l.View()
381− assert.Equal(t, viewBefore, viewAfter)
382− assert.Equal(t, 4, l.offset)
383− assert.Equal(t, 32, lipgloss.Height(l.rendered))
384− golden.RequireEqual(t, []byte(l.View()))
385− })
386− 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) {
387− t.Parallel()
388− items := []Item{}
389− for i := range 30 {
390− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
391− items = append(items, item)
392− }
393− items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
394− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
395− execCmd(l, l.Init())
396−
397− execCmd(l, l.MoveUp(2))
398− viewBefore := l.View()
399− item := items[30]
400− execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
401− viewAfter := l.View()
402− assert.Equal(t, viewBefore, viewAfter)
403− assert.Equal(t, 0, l.offset)
404− assert.Equal(t, 31, lipgloss.Height(l.rendered))
405− golden.RequireEqual(t, []byte(l.View()))
406− })
407− 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) {
408− t.Parallel()
409− items := []Item{}
410− for i := range 30 {
411− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
412− items = append(items, item)
413− }
414− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
415− execCmd(l, l.Init())
416−
417− execCmd(l, l.MoveUp(2))
418− viewBefore := l.View()
419− item := items[1]
420− execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
421− viewAfter := l.View()
422− assert.Equal(t, viewBefore, viewAfter)
423− assert.Equal(t, 2, l.offset)
424− assert.Equal(t, 32, lipgloss.Height(l.rendered))
425− golden.RequireEqual(t, []byte(l.View()))
426− })
427− t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
428− t.Parallel()
429− items := []Item{}
430− for i := range 30 {
431− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
432− items = append(items, item)
433− }
434− l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
435− execCmd(l, l.Init())
436−
437− execCmd(l, l.MoveUp(2))
438− viewBefore := l.View()
439− execCmd(l, l.PrependItem(NewSelectableItem("New")))
440− viewAfter := l.View()
441− assert.Equal(t, viewBefore, viewAfter)
442− assert.Equal(t, 2, l.offset)
443− assert.Equal(t, 31, lipgloss.Height(l.rendered))
444− golden.RequireEqual(t, []byte(l.View()))
445− })
446−
447− t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
448− t.Parallel()
449− items := []Item{}
450− for i := range 30 {
451− content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
452− content = strings.TrimSuffix(content, "\n")
453− item := NewSelectableItem(content)
454− items = append(items, item)
455− }
456− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
457− execCmd(l, l.Init())
458− execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
459−
460− assert.Equal(t, 0, l.offset)
461− golden.RequireEqual(t, []byte(l.View()))
462− })
463−
464− 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) {
465− t.Parallel()
466− items := []Item{}
467− for i := range 30 {
468− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
469− items = append(items, item)
470− }
471− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
472− execCmd(l, l.Init())
473−
474− execCmd(l, l.MoveDown(2))
475− viewBefore := l.View()
476− execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
477− viewAfter := l.View()
478− assert.Equal(t, viewBefore, viewAfter)
479− assert.Equal(t, 5, l.offset)
480− assert.Equal(t, 33, lipgloss.Height(l.rendered))
481− golden.RequireEqual(t, []byte(l.View()))
482− })
483−
484− 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) {
485− t.Parallel()
486− items := []Item{}
487− for i := range 30 {
488− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
489− items = append(items, item)
490− }
491− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
492− execCmd(l, l.Init())
493−
494− execCmd(l, l.MoveDown(2))
495− viewBefore := l.View()
496− item := items[0]
497− execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
498− viewAfter := l.View()
499− assert.Equal(t, viewBefore, viewAfter)
500− assert.Equal(t, 4, l.offset)
501− assert.Equal(t, 32, lipgloss.Height(l.rendered))
502− golden.RequireEqual(t, []byte(l.View()))
503− })
504−
505− 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) {
506− t.Parallel()
507− items := []Item{}
508− items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
509− for i := range 30 {
510− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
511− items = append(items, item)
512− }
513− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
514− execCmd(l, l.Init())
515−
516− execCmd(l, l.MoveDown(3))
517− viewBefore := l.View()
518− item := items[0]
519− execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
520− viewAfter := l.View()
521− assert.Equal(t, viewBefore, viewAfter)
522− assert.Equal(t, 1, l.offset)
523− assert.Equal(t, 31, lipgloss.Height(l.rendered))
524− golden.RequireEqual(t, []byte(l.View()))
525− })
526−
527− 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) {
528− t.Parallel()
529− items := []Item{}
530− for i := range 30 {
531− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
532− items = append(items, item)
533− }
534− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
535− execCmd(l, l.Init())
536−
537− execCmd(l, l.MoveDown(2))
538− viewBefore := l.View()
539− item := items[29]
540− execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
541− viewAfter := l.View()
542− assert.Equal(t, viewBefore, viewAfter)
543− assert.Equal(t, 2, l.offset)
544− assert.Equal(t, 32, lipgloss.Height(l.rendered))
545− golden.RequireEqual(t, []byte(l.View()))
546− })
547− t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
548− t.Parallel()
549− items := []Item{}
550− for i := range 30 {
551− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
552− items = append(items, item)
553− }
554− l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
555− execCmd(l, l.Init())
556−
557− execCmd(l, l.MoveDown(2))
558− viewBefore := l.View()
559− execCmd(l, l.AppendItem(NewSelectableItem("New")))
560− viewAfter := l.View()
561− assert.Equal(t, viewBefore, viewAfter)
562− assert.Equal(t, 2, l.offset)
563− assert.Equal(t, 31, lipgloss.Height(l.rendered))
564− golden.RequireEqual(t, []byte(l.View()))
565− })
566−}
567−
568−type SelectableItem interface {
569− Item
570− layout.Focusable
571−}
572−
573−type simpleItem struct {
574− width int
575− content string
576− id string
577−}
578−type selectableItem struct {
579− *simpleItem
580− focused bool
581−}
582−
583−func NewSimpleItem(content string) *simpleItem {
584− return &simpleItem{
585− id: uuid.NewString(),
586− width: 0,
587− content: content,
588− }
589−}
590−
591−func NewSelectableItem(content string) SelectableItem {
592− return &selectableItem{
593− simpleItem: NewSimpleItem(content),
594− focused: false,
595− }
596−}
597−
598−func (s *simpleItem) ID() string {
599− return s.id
600−}
601−
602−func (s *simpleItem) Init() tea.Cmd {
603− return nil
604−}
605−
606−func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) {
607− return s, nil
608−}
609−
610−func (s *simpleItem) View() string {
611− return lipgloss.NewStyle().Width(s.width).Render(s.content)
612−}
613−
614−func (l *simpleItem) GetSize() (int, int) {
615− return l.width, 0
616−}
617−
618−// SetSize implements Item.
619−func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
620− s.width = width
621− return nil
622−}
623−
624−func (s *selectableItem) View() string {
625− if s.focused {
626− return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
627− }
628− return lipgloss.NewStyle().Width(s.width).Render(s.content)
629−}
630−
631−// Blur implements SimpleItem.
632−func (s *selectableItem) Blur() tea.Cmd {
633− s.focused = false
634− return nil
635−}
636−
637−// Focus implements SimpleItem.
638−func (s *selectableItem) Focus() tea.Cmd {
639− s.focused = true
640− return nil
641−}
642−
643−// IsFocused implements SimpleItem.
644−func (s *selectableItem) IsFocused() bool {
645− return s.focused
646−}
647−
648−func execCmd(m util.Model, cmd tea.Cmd) {
649− for cmd != nil {
650− msg := cmd()
651− m, cmd = m.Update(msg)
652− }
653−}
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.goldendeleted−10
@@ -1,10 +0,0 @@
1−> Type to filter 
2−│Item 0 
3−Item 1 
4−Item 2 
5−Item 3 
6−Item 4 
7−
8−
9−
10−
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 10
2−│Item 10
3−│Item 10
4−│Item 10
5−│Item 10
6−│Item 10
7−│Item 10
8−│Item 10
9−│Item 10
10−│Item 10
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 10
2−│Item 10
3−│Item 10
4−│Item 10
5−│Item 10
6−│Item 10
7−│Item 10
8−│Item 10
9−│Item 10
10−│Item 10
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
6−Item 5
7−Item 6
8−Item 7
9−Item 8
10−Item 9
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 0
2−Item 1
3−Item 1
4−Item 2
5−Item 2
6−Item 2
7−Item 3
8−Item 3
9−Item 3
10−Item 3
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 29
2−│Item 29
3−│Item 29
4−│Item 29
5−│Item 29
6−│Item 29
7−│Item 29
8−│Item 29
9−│Item 29
10−│Item 29
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 20
2−Item 21
3−Item 22
4−Item 23
5−Item 24
6−Item 25
7−Item 26
8−Item 27
9−Item 28
10−│Item 29
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.goldendeleted−20
@@ -1,20 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
6−
7−
8−
9−
10−
11−
12−
13−
14−
15−
16−
17−
18−
19−
20−
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.goldendeleted−20
@@ -1,20 +0,0 @@
1−Item 0
2−Item 1
3−Item 2
4−Item 3
5−│Item 4
6−
7−
8−
9−
10−
11−
12−
13−
14−
15−
16−
17−
18−
19−
20−
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 6
2−Item 6
3−Item 6
4−│Item 7
5−│Item 7
6−│Item 7
7−│Item 7
8−│Item 7
9−│Item 7
10−│Item 7
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 0
2−Item 1
3−Item 1
4−Item 2
5−Item 2
6−Item 2
7−│Item 3
8−│Item 3
9−│Item 3
10−│Item 3
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 28
2−│Item 28
3−│Item 28
4−│Item 28
5−│Item 28
6−Item 29
7−Item 29
8−Item 29
9−Item 29
10−Item 29
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 29
2−│Item 29
3−│Item 29
4−│Item 29
5−│Item 29
6−│Item 29
7−│Item 29
8−│Item 29
9−│Item 29
10−│Item 29
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 29
2−Item 29
3−Item 29
4−Item 29
5−Item 29
6−Item 29
7−Item 29
8−Item 29
9−Item 29
10−│Testing 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Testing 
2−Item 0
3−Item 1
4−Item 1
5−Item 2
6−Item 2
7−Item 2
8−Item 3
9−Item 3
10−Item 3
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 2
2−Item 3
3−Item 4
4−Item 5
5−Item 6
6−Item 7
7−Item 8
8−Item 9
9−Item 10
10−Item 11
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 18
2−Item 19
3−Item 20
4−Item 21
5−Item 22
6−Item 23
7−Item 24
8−Item 25
9−Item 26
10−│Item 27
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.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 2
2−Item 3
3−Item 4
4−Item 5
5−Item 6
6−Item 7
7−Item 8
8−Item 9
9−Item 10
10−Item 11
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.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 18
2−Item 19
3−Item 20
4−Item 21
5−Item 22
6−Item 23
7−Item 24
8−Item 25
9−Item 26
10−│Item 27
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.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
6−Item 5
7−Item 6
8−Item 7
9−Item 8
10−Item 9
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.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 18
2−Item 19
3−Item 20
4−Item 21
5−Item 22
6−Item 23
7−Item 24
8−Item 25
9−Item 26
10−│Item 27
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.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 2
2−Item 3
3−Item 4
4−Item 5
5−Item 6
6−Item 7
7−Item 8
8−Item 9
9−Item 10
10−Item 11
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.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 21
2−Item 22
3−Item 23
4−Item 24
5−Item 25
6−Item 26
7−Item 27
8−Item 28
9−│Item 29
10−Item 30
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.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 18
2−Item 19
3−Item 20
4−Item 21
5−Item 22
6−Item 23
7−Item 24
8−Item 25
9−Item 26
10−│Item 27
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.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 2
2−Item 3
3−Item 4
4−Item 5
5−Item 6
6−Item 7
7−Item 8
8−Item 9
9−Item 10
10−Item 11
internal/tui/highlight/highlight.godeleted−54
@@ -1,54 +0,0 @@
1−package highlight
2−
3−import (
4− "bytes"
5− "image/color"
6−
7− "github.com/alecthomas/chroma/v2"
8− "github.com/alecthomas/chroma/v2/formatters"
9− "github.com/alecthomas/chroma/v2/lexers"
10− chromaStyles "github.com/alecthomas/chroma/v2/styles"
11− "github.com/charmbracelet/crush/internal/tui/styles"
12−)
13−
14−func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
15− // Determine the language lexer to use
16− l := lexers.Match(fileName)
17− if l == nil {
18− l = lexers.Analyse(source)
19− }
20− if l == nil {
21− l = lexers.Fallback
22− }
23− l = chroma.Coalesce(l)
24−
25− // Get the formatter
26− f := formatters.Get("terminal16m")
27− if f == nil {
28− f = formatters.Fallback
29− }
30−
31− style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
32−
33− // Modify the style to use the provided background
34− s, err := style.Builder().Transform(
35− func(t chroma.StyleEntry) chroma.StyleEntry {
36− r, g, b, _ := bg.RGBA()
37− t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
38− return t
39− },
40− ).Build()
41− if err != nil {
42− s = chromaStyles.Fallback
43− }
44−
45− // Tokenize and format
46− it, err := l.Tokenise(nil, source)
47− if err != nil {
48− return "", err
49− }
50−
51− var buf bytes.Buffer
52− err = f.Format(&buf, s, it)
53− return buf.String(), err
54−}
internal/tui/keys.godeleted−45
@@ -1,45 +0,0 @@
1−package tui
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Quit key.Binding
9− Help key.Binding
10− Commands key.Binding
11− Suspend key.Binding
12− Models key.Binding
13− Sessions key.Binding
14−
15− pageBindings []key.Binding
16−}
17−
18−func DefaultKeyMap() KeyMap {
19− return KeyMap{
20− Quit: key.NewBinding(
21− key.WithKeys("ctrl+c"),
22− key.WithHelp("ctrl+c", "quit"),
23− ),
24− Help: key.NewBinding(
25− key.WithKeys("ctrl+g"),
26− key.WithHelp("ctrl+g", "more"),
27− ),
28− Commands: key.NewBinding(
29− key.WithKeys("ctrl+p"),
30− key.WithHelp("ctrl+p", "commands"),
31− ),
32− Suspend: key.NewBinding(
33− key.WithKeys("ctrl+z"),
34− key.WithHelp("ctrl+z", "suspend"),
35− ),
36− Models: key.NewBinding(
37− key.WithKeys("ctrl+l", "ctrl+m"),
38− key.WithHelp("ctrl+l", "models"),
39− ),
40− Sessions: key.NewBinding(
41− key.WithKeys("ctrl+s"),
42− key.WithHelp("ctrl+s", "sessions"),
43− ),
44− }
45−}
internal/tui/page/chat/chat.godeleted−1407
@@ -1,1407 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "errors"
6− "fmt"
7− "time"
8−
9− "charm.land/bubbles/v2/help"
10− "charm.land/bubbles/v2/key"
11− "charm.land/bubbles/v2/spinner"
12− tea "charm.land/bubbletea/v2"
13− "charm.land/lipgloss/v2"
14− "github.com/charmbracelet/crush/internal/app"
15− "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/history"
17− "github.com/charmbracelet/crush/internal/message"
18− "github.com/charmbracelet/crush/internal/permission"
19− "github.com/charmbracelet/crush/internal/pubsub"
20− "github.com/charmbracelet/crush/internal/session"
21− "github.com/charmbracelet/crush/internal/tui/components/anim"
22− "github.com/charmbracelet/crush/internal/tui/components/chat"
23− "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
24− "github.com/charmbracelet/crush/internal/tui/components/chat/header"
25− "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
26− "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
27− "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
28− "github.com/charmbracelet/crush/internal/tui/components/completions"
29− "github.com/charmbracelet/crush/internal/tui/components/core"
30− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
31− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
32− "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
33− "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
34− "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
35− "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
36− "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
37− "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
38− "github.com/charmbracelet/crush/internal/tui/page"
39− "github.com/charmbracelet/crush/internal/tui/styles"
40− "github.com/charmbracelet/crush/internal/tui/util"
41− "github.com/charmbracelet/crush/internal/version"
42−)
43−
44−var ChatPageID page.PageID = "chat"
45−
46−type (
47− ChatFocusedMsg struct {
48− Focused bool
49− }
50− CancelTimerExpiredMsg struct{}
51−)
52−
53−type PanelType string
54−
55−const (
56− PanelTypeChat PanelType = "chat"
57− PanelTypeEditor PanelType = "editor"
58− PanelTypeSplash PanelType = "splash"
59−)
60−
61−// PillSection represents which pill section is focused when in pills panel.
62−type PillSection int
63−
64−const (
65− PillSectionTodos PillSection = iota
66− PillSectionQueue
67−)
68−
69−const (
70− CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
71− CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
72− EditorHeight = 5 // Height of the editor input area including padding
73− SideBarWidth = 31 // Width of the sidebar
74− SideBarDetailsPadding = 1 // Padding for the sidebar details section
75− HeaderHeight = 1 // Height of the header
76−
77− // Layout constants for borders and padding
78− BorderWidth = 1 // Width of component borders
79− LeftRightBorders = 2 // Left + right border width (1 + 1)
80− TopBottomBorders = 2 // Top + bottom border width (1 + 1)
81− DetailsPositioning = 2 // Positioning adjustment for details panel
82−
83− // Timing constants
84− CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
85−)
86−
87−type ChatPage interface {
88− util.Model
89− layout.Help
90− IsChatFocused() bool
91−}
92−
93−// cancelTimerCmd creates a command that expires the cancel timer
94−func cancelTimerCmd() tea.Cmd {
95− return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
96− return CancelTimerExpiredMsg{}
97− })
98−}
99−
100−type chatPage struct {
101− width, height int
102− detailsWidth, detailsHeight int
103− app *app.App
104− keyboardEnhancements tea.KeyboardEnhancementsMsg
105−
106− // Layout state
107− compact bool
108− forceCompact bool
109− focusedPane PanelType
110−
111− // Session
112− session session.Session
113− keyMap KeyMap
114−
115− // Components
116− header header.Header
117− sidebar sidebar.Sidebar
118− chat chat.MessageListCmp
119− editor editor.Editor
120− splash splash.Splash
121−
122− // Simple state flags
123− showingDetails bool
124− isCanceling bool
125− splashFullScreen bool
126− isOnboarding bool
127− isProjectInit bool
128− promptQueue int
129−
130− // Pills state
131− pillsExpanded bool
132− focusedPillSection PillSection
133−
134− // Todo spinner
135− todoSpinner spinner.Model
136−}
137−
138−func New(app *app.App) ChatPage {
139− t := styles.CurrentTheme()
140− return &chatPage{
141− app: app,
142− keyMap: DefaultKeyMap(),
143− header: header.New(app.LSPClients),
144− sidebar: sidebar.New(app.History, app.LSPClients, false),
145− chat: chat.New(app),
146− editor: editor.New(app),
147− splash: splash.New(),
148− focusedPane: PanelTypeSplash,
149− todoSpinner: spinner.New(
150− spinner.WithSpinner(spinner.MiniDot),
151− spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
152− ),
153− }
154−}
155−
156−func (p *chatPage) Init() tea.Cmd {
157− cfg := config.Get()
158− compact := cfg.Options.TUI.CompactMode
159− p.compact = compact
160− p.forceCompact = compact
161− p.sidebar.SetCompactMode(p.compact)
162−
163− // Set splash state based on config
164− if !config.HasInitialDataConfig() {
165− // First-time setup: show model selection
166− p.splash.SetOnboarding(true)
167− p.isOnboarding = true
168− p.splashFullScreen = true
169− } else if b, _ := config.ProjectNeedsInitialization(); b {
170− // Project needs context initialization
171− p.splash.SetProjectInit(true)
172− p.isProjectInit = true
173− p.splashFullScreen = true
174− } else {
175− // Ready to chat: focus editor, splash in background
176− p.focusedPane = PanelTypeEditor
177− p.splashFullScreen = false
178− }
179−
180− return tea.Batch(
181− p.header.Init(),
182− p.sidebar.Init(),
183− p.chat.Init(),
184− p.editor.Init(),
185− p.splash.Init(),
186− )
187−}
188−
189−func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
190− var cmds []tea.Cmd
191− if p.session.ID != "" && p.app.AgentCoordinator != nil {
192− queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
193− if queueSize != p.promptQueue {
194− p.promptQueue = queueSize
195− cmds = append(cmds, p.SetSize(p.width, p.height))
196− }
197− }
198− switch msg := msg.(type) {
199− case tea.KeyboardEnhancementsMsg:
200− p.keyboardEnhancements = msg
201− return p, nil
202− case tea.MouseWheelMsg:
203− if p.compact {
204− msg.Y -= 1
205− }
206− if p.isMouseOverChat(msg.X, msg.Y) {
207− u, cmd := p.chat.Update(msg)
208− p.chat = u.(chat.MessageListCmp)
209− return p, cmd
210− }
211− return p, nil
212− case tea.MouseClickMsg:
213− if p.isOnboarding || p.isProjectInit {
214− return p, nil
215− }
216− if p.compact {
217− msg.Y -= 1
218− }
219− if p.isMouseOverChat(msg.X, msg.Y) {
220− p.focusedPane = PanelTypeChat
221− p.chat.Focus()
222− p.editor.Blur()
223− } else {
224− p.focusedPane = PanelTypeEditor
225− p.editor.Focus()
226− p.chat.Blur()
227− }
228− u, cmd := p.chat.Update(msg)
229− p.chat = u.(chat.MessageListCmp)
230− return p, cmd
231− case tea.MouseMotionMsg:
232− if p.compact {
233− msg.Y -= 1
234− }
235− if msg.Button == tea.MouseLeft {
236− u, cmd := p.chat.Update(msg)
237− p.chat = u.(chat.MessageListCmp)
238− return p, cmd
239− }
240− return p, nil
241− case tea.MouseReleaseMsg:
242− if p.isOnboarding || p.isProjectInit {
243− return p, nil
244− }
245− if p.compact {
246− msg.Y -= 1
247− }
248− if msg.Button == tea.MouseLeft {
249− u, cmd := p.chat.Update(msg)
250− p.chat = u.(chat.MessageListCmp)
251− return p, cmd
252− }
253− return p, nil
254− case chat.SelectionCopyMsg:
255− u, cmd := p.chat.Update(msg)
256− p.chat = u.(chat.MessageListCmp)
257− return p, cmd
258− case tea.WindowSizeMsg:
259− u, cmd := p.editor.Update(msg)
260− p.editor = u.(editor.Editor)
261− return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
262− case CancelTimerExpiredMsg:
263− p.isCanceling = false
264− return p, nil
265− case editor.OpenEditorMsg:
266− u, cmd := p.editor.Update(msg)
267− p.editor = u.(editor.Editor)
268− return p, cmd
269− case chat.SendMsg:
270− return p, p.sendMessage(msg.Text, msg.Attachments)
271− case chat.SessionSelectedMsg:
272− return p, p.setSession(msg)
273− case splash.SubmitAPIKeyMsg:
274− u, cmd := p.splash.Update(msg)
275− p.splash = u.(splash.Splash)
276− cmds = append(cmds, cmd)
277− return p, tea.Batch(cmds...)
278− case commands.ToggleCompactModeMsg:
279− p.forceCompact = !p.forceCompact
280− var cmd tea.Cmd
281− if p.forceCompact {
282− p.setCompactMode(true)
283− cmd = p.updateCompactConfig(true)
284− } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
285− p.setCompactMode(false)
286− cmd = p.updateCompactConfig(false)
287− }
288− return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
289− case commands.ToggleThinkingMsg:
290− return p, p.toggleThinking()
291− case commands.OpenReasoningDialogMsg:
292− return p, p.openReasoningDialog()
293− case reasoning.ReasoningEffortSelectedMsg:
294− return p, p.handleReasoningEffortSelected(msg.Effort)
295− case commands.OpenExternalEditorMsg:
296− u, cmd := p.editor.Update(msg)
297− p.editor = u.(editor.Editor)
298− return p, cmd
299− case pubsub.Event[session.Session]:
300− if msg.Payload.ID == p.session.ID {
301− prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
302− prevHasInProgress := p.hasInProgressTodo()
303− p.session = msg.Payload
304− newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
305− newHasInProgress := p.hasInProgressTodo()
306− if prevHasIncompleteTodos != newHasIncompleteTodos {
307− cmds = append(cmds, p.SetSize(p.width, p.height))
308− }
309− if !prevHasInProgress && newHasInProgress {
310− cmds = append(cmds, p.todoSpinner.Tick)
311− }
312− }
313− u, cmd := p.header.Update(msg)
314− p.header = u.(header.Header)
315− cmds = append(cmds, cmd)
316− u, cmd = p.sidebar.Update(msg)
317− p.sidebar = u.(sidebar.Sidebar)
318− cmds = append(cmds, cmd)
319− return p, tea.Batch(cmds...)
320− case chat.SessionClearedMsg:
321− u, cmd := p.header.Update(msg)
322− p.header = u.(header.Header)
323− cmds = append(cmds, cmd)
324− u, cmd = p.sidebar.Update(msg)
325− p.sidebar = u.(sidebar.Sidebar)
326− cmds = append(cmds, cmd)
327− u, cmd = p.chat.Update(msg)
328− p.chat = u.(chat.MessageListCmp)
329− cmds = append(cmds, cmd)
330− u, cmd = p.editor.Update(msg)
331− p.editor = u.(editor.Editor)
332− cmds = append(cmds, cmd)
333− return p, tea.Batch(cmds...)
334− case filepicker.FilePickedMsg,
335− completions.CompletionsClosedMsg,
336− completions.SelectCompletionMsg:
337− u, cmd := p.editor.Update(msg)
338− p.editor = u.(editor.Editor)
339− cmds = append(cmds, cmd)
340− return p, tea.Batch(cmds...)
341−
342− case hyper.DeviceFlowCompletedMsg,
343− hyper.DeviceAuthInitiatedMsg,
344− hyper.DeviceFlowErrorMsg,
345− copilot.DeviceAuthInitiatedMsg,
346− copilot.DeviceFlowErrorMsg,
347− copilot.DeviceFlowCompletedMsg:
348− if p.focusedPane == PanelTypeSplash {
349− u, cmd := p.splash.Update(msg)
350− p.splash = u.(splash.Splash)
351− cmds = append(cmds, cmd)
352− }
353− return p, tea.Batch(cmds...)
354− case models.APIKeyStateChangeMsg:
355− if p.focusedPane == PanelTypeSplash {
356− u, cmd := p.splash.Update(msg)
357− p.splash = u.(splash.Splash)
358− cmds = append(cmds, cmd)
359− }
360− return p, tea.Batch(cmds...)
361− case pubsub.Event[message.Message],
362− anim.StepMsg,
363− spinner.TickMsg:
364− // Update todo spinner if agent is busy and we have in-progress todos
365− agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
366− if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy {
367− var cmd tea.Cmd
368− p.todoSpinner, cmd = p.todoSpinner.Update(msg)
369− cmds = append(cmds, cmd)
370− }
371− // Start spinner when agent becomes busy and we have in-progress todos
372− if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy {
373− cmds = append(cmds, p.todoSpinner.Tick)
374− }
375− if p.focusedPane == PanelTypeSplash {
376− u, cmd := p.splash.Update(msg)
377− p.splash = u.(splash.Splash)
378− cmds = append(cmds, cmd)
379− } else {
380− u, cmd := p.chat.Update(msg)
381− p.chat = u.(chat.MessageListCmp)
382− cmds = append(cmds, cmd)
383− }
384−
385− return p, tea.Batch(cmds...)
386− case commands.ToggleYoloModeMsg:
387− // update the editor style
388− u, cmd := p.editor.Update(msg)
389− p.editor = u.(editor.Editor)
390− return p, cmd
391− case pubsub.Event[history.File], sidebar.SessionFilesMsg:
392− u, cmd := p.sidebar.Update(msg)
393− p.sidebar = u.(sidebar.Sidebar)
394− cmds = append(cmds, cmd)
395− return p, tea.Batch(cmds...)
396− case pubsub.Event[permission.PermissionNotification]:
397− u, cmd := p.chat.Update(msg)
398− p.chat = u.(chat.MessageListCmp)
399− cmds = append(cmds, cmd)
400− return p, tea.Batch(cmds...)
401−
402− case commands.CommandRunCustomMsg:
403− if p.app.AgentCoordinator.IsBusy() {
404− return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
405− }
406−
407− cmd := p.sendMessage(msg.Content, nil)
408− if cmd != nil {
409− return p, cmd
410− }
411− case splash.OnboardingCompleteMsg:
412− p.splashFullScreen = false
413− if b, _ := config.ProjectNeedsInitialization(); b {
414− p.splash.SetProjectInit(true)
415− p.splashFullScreen = true
416− return p, p.SetSize(p.width, p.height)
417− }
418− err := p.app.InitCoderAgent(context.TODO())
419− if err != nil {
420− return p, util.ReportError(err)
421− }
422− p.isOnboarding = false
423− p.isProjectInit = false
424− p.focusedPane = PanelTypeEditor
425− return p, p.SetSize(p.width, p.height)
426− case commands.NewSessionsMsg:
427− if p.app.AgentCoordinator.IsBusy() {
428− return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
429− }
430− return p, p.newSession()
431− case tea.KeyPressMsg:
432− switch {
433− case key.Matches(msg, p.keyMap.NewSession):
434− // if we have no agent do nothing
435− if p.app.AgentCoordinator == nil {
436− return p, nil
437− }
438− if p.app.AgentCoordinator.IsBusy() {
439− return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
440− }
441− return p, p.newSession()
442− case key.Matches(msg, p.keyMap.AddAttachment):
443− // Skip attachment handling during onboarding/splash screen
444− if p.focusedPane == PanelTypeSplash || p.isOnboarding {
445− u, cmd := p.splash.Update(msg)
446− p.splash = u.(splash.Splash)
447− return p, cmd
448− }
449− agentCfg := config.Get().Agents[config.AgentCoder]
450− model := config.Get().GetModelByType(agentCfg.Model)
451− if model == nil {
452− return p, util.ReportWarn("No model configured yet")
453− }
454− if model.SupportsImages {
455− return p, util.CmdHandler(commands.OpenFilePickerMsg{})
456− } else {
457− return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
458− }
459− case key.Matches(msg, p.keyMap.Tab):
460− if p.session.ID == "" {
461− u, cmd := p.splash.Update(msg)
462− p.splash = u.(splash.Splash)
463− return p, cmd
464− }
465− return p, p.changeFocus()
466− case key.Matches(msg, p.keyMap.Cancel):
467− if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
468− return p, p.cancel()
469− }
470− case key.Matches(msg, p.keyMap.Details):
471− p.toggleDetails()
472− return p, nil
473− case key.Matches(msg, p.keyMap.TogglePills):
474− if p.session.ID != "" {
475− return p, p.togglePillsExpanded()
476− }
477− case key.Matches(msg, p.keyMap.PillLeft):
478− if p.session.ID != "" && p.pillsExpanded {
479− return p, p.switchPillSection(-1)
480− }
481− case key.Matches(msg, p.keyMap.PillRight):
482− if p.session.ID != "" && p.pillsExpanded {
483− return p, p.switchPillSection(1)
484− }
485− }
486−
487− switch p.focusedPane {
488− case PanelTypeChat:
489− u, cmd := p.chat.Update(msg)
490− p.chat = u.(chat.MessageListCmp)
491− cmds = append(cmds, cmd)
492− case PanelTypeEditor:
493− u, cmd := p.editor.Update(msg)
494− p.editor = u.(editor.Editor)
495− cmds = append(cmds, cmd)
496− case PanelTypeSplash:
497− u, cmd := p.splash.Update(msg)
498− p.splash = u.(splash.Splash)
499− cmds = append(cmds, cmd)
500− }
501− case tea.PasteMsg:
502− switch p.focusedPane {
503− case PanelTypeEditor:
504− u, cmd := p.editor.Update(msg)
505− p.editor = u.(editor.Editor)
506− cmds = append(cmds, cmd)
507− return p, tea.Batch(cmds...)
508− case PanelTypeChat:
509− u, cmd := p.chat.Update(msg)
510− p.chat = u.(chat.MessageListCmp)
511− cmds = append(cmds, cmd)
512− return p, tea.Batch(cmds...)
513− case PanelTypeSplash:
514− u, cmd := p.splash.Update(msg)
515− p.splash = u.(splash.Splash)
516− cmds = append(cmds, cmd)
517− return p, tea.Batch(cmds...)
518− }
519− }
520− return p, tea.Batch(cmds...)
521−}
522−
523−func (p *chatPage) Cursor() *tea.Cursor {
524− if p.header.ShowingDetails() {
525− return nil
526− }
527− switch p.focusedPane {
528− case PanelTypeEditor:
529− return p.editor.Cursor()
530− case PanelTypeSplash:
531− return p.splash.Cursor()
532− default:
533− return nil
534− }
535−}
536−
537−func (p *chatPage) View() string {
538− var chatView string
539− t := styles.CurrentTheme()
540−
541− if p.session.ID == "" {
542− splashView := p.splash.View()
543− // Full screen during onboarding or project initialization
544− if p.splashFullScreen {
545− chatView = splashView
546− } else {
547− // Show splash + editor for new message state
548− editorView := p.editor.View()
549− chatView = lipgloss.JoinVertical(
550− lipgloss.Left,
551− t.S().Base.Render(splashView),
552− editorView,
553− )
554− }
555− } else {
556− messagesView := p.chat.View()
557− editorView := p.editor.View()
558−
559− hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
560− hasQueue := p.promptQueue > 0
561− todosFocused := p.pillsExpanded && p.focusedPillSection == PillSectionTodos
562− queueFocused := p.pillsExpanded && p.focusedPillSection == PillSectionQueue
563−
564− // Use spinner when agent is busy, otherwise show static icon
565− agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
566− inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon)
567− if agentBusy {
568− inProgressIcon = p.todoSpinner.View()
569− }
570−
571− var pills []string
572− if hasIncompleteTodos {
573− pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, p.pillsExpanded, t))
574− }
575− if hasQueue {
576− pills = append(pills, queuePill(p.promptQueue, queueFocused, p.pillsExpanded, t))
577− }
578−
579− var expandedList string
580− if p.pillsExpanded {
581− if todosFocused && hasIncompleteTodos {
582− expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth)
583− } else if queueFocused && hasQueue {
584− queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID)
585− expandedList = queueList(queueItems, t)
586− }
587− }
588−
589− var pillsArea string
590− if len(pills) > 0 {
591− pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
592−
593− // Add help hint for expanding/collapsing pills based on state.
594− var helpDesc string
595− if p.pillsExpanded {
596− helpDesc = "close"
597− } else {
598− helpDesc = "open"
599− }
600− // Style to match help section: keys in FgMuted, description in FgSubtle
601− helpKey := t.S().Base.Foreground(t.FgMuted).Render("ctrl+space")
602− helpText := t.S().Base.Foreground(t.FgSubtle).Render(helpDesc)
603− helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
604− pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
605−
606− if expandedList != "" {
607− pillsArea = lipgloss.JoinVertical(
608− lipgloss.Left,
609− pillsRow,
610− expandedList,
611− )
612− } else {
613− pillsArea = pillsRow
614− }
615−
616− pillsArea = t.S().Base.
617− MaxWidth(p.width).
618− MarginTop(1).
619− PaddingLeft(3).
620− Render(pillsArea)
621− }
622−
623− if p.compact {
624− headerView := p.header.View()
625− views := []string{headerView, messagesView}
626− if pillsArea != "" {
627− views = append(views, pillsArea)
628− }
629− views = append(views, editorView)
630− chatView = lipgloss.JoinVertical(lipgloss.Left, views...)
631− } else {
632− sidebarView := p.sidebar.View()
633− var messagesColumn string
634− if pillsArea != "" {
635− messagesColumn = lipgloss.JoinVertical(
636− lipgloss.Left,
637− messagesView,
638− pillsArea,
639− )
640− } else {
641− messagesColumn = messagesView
642− }
643− messages := lipgloss.JoinHorizontal(
644− lipgloss.Left,
645− messagesColumn,
646− sidebarView,
647− )
648− chatView = lipgloss.JoinVertical(
649− lipgloss.Left,
650− messages,
651− p.editor.View(),
652− )
653− }
654− }
655−
656− layers := []*lipgloss.Layer{
657− lipgloss.NewLayer(chatView).X(0).Y(0),
658− }
659−
660− if p.showingDetails {
661− style := t.S().Base.
662− Width(p.detailsWidth).
663− Border(lipgloss.RoundedBorder()).
664− BorderForeground(t.BorderFocus)
665− version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
666− details := style.Render(
667− lipgloss.JoinVertical(
668− lipgloss.Left,
669− p.sidebar.View(),
670− version,
671− ),
672− )
673− layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
674− }
675− canvas := lipgloss.NewCompositor(layers...)
676− return canvas.Render()
677−}
678−
679−func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
680− return func() tea.Msg {
681− err := config.Get().SetCompactMode(compact)
682− if err != nil {
683− return util.InfoMsg{
684− Type: util.InfoTypeError,
685− Msg: "Failed to update compact mode configuration: " + err.Error(),
686− }
687− }
688− return nil
689− }
690−}
691−
692−func (p *chatPage) toggleThinking() tea.Cmd {
693− return func() tea.Msg {
694− cfg := config.Get()
695− agentCfg := cfg.Agents[config.AgentCoder]
696− currentModel := cfg.Models[agentCfg.Model]
697−
698− // Toggle the thinking mode
699− currentModel.Think = !currentModel.Think
700− if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
701− return util.InfoMsg{
702− Type: util.InfoTypeError,
703− Msg: "Failed to update thinking mode: " + err.Error(),
704− }
705− }
706−
707− // Update the agent with the new configuration
708− go p.app.UpdateAgentModel(context.TODO())
709−
710− status := "disabled"
711− if currentModel.Think {
712− status = "enabled"
713− }
714− return util.InfoMsg{
715− Type: util.InfoTypeInfo,
716− Msg: "Thinking mode " + status,
717− }
718− }
719−}
720−
721−func (p *chatPage) openReasoningDialog() tea.Cmd {
722− return func() tea.Msg {
723− cfg := config.Get()
724− agentCfg := cfg.Agents[config.AgentCoder]
725− model := cfg.GetModelByType(agentCfg.Model)
726− providerCfg := cfg.GetProviderForModel(agentCfg.Model)
727−
728− if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
729− // Return the OpenDialogMsg directly so it bubbles up to the main TUI
730− return dialogs.OpenDialogMsg{
731− Model: reasoning.NewReasoningDialog(),
732− }
733− }
734− return nil
735− }
736−}
737−
738−func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
739− return func() tea.Msg {
740− cfg := config.Get()
741− agentCfg := cfg.Agents[config.AgentCoder]
742− currentModel := cfg.Models[agentCfg.Model]
743−
744− // Update the model configuration
745− currentModel.ReasoningEffort = effort
746− if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
747− return util.InfoMsg{
748− Type: util.InfoTypeError,
749− Msg: "Failed to update reasoning effort: " + err.Error(),
750− }
751− }
752−
753− // Update the agent with the new configuration
754− if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
755− return util.InfoMsg{
756− Type: util.InfoTypeError,
757− Msg: "Failed to update reasoning effort: " + err.Error(),
758− }
759− }
760−
761− return util.InfoMsg{
762− Type: util.InfoTypeInfo,
763− Msg: "Reasoning effort set to " + effort,
764− }
765− }
766−}
767−
768−func (p *chatPage) setCompactMode(compact bool) {
769− if p.compact == compact {
770− return
771− }
772− p.compact = compact
773− if compact {
774− p.sidebar.SetCompactMode(true)
775− } else {
776− p.setShowDetails(false)
777− }
778−}
779−
780−func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
781− if p.forceCompact {
782− return
783− }
784− if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
785− p.setCompactMode(true)
786− }
787− if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
788− p.setCompactMode(false)
789− }
790−}
791−
792−func (p *chatPage) SetSize(width, height int) tea.Cmd {
793− p.handleCompactMode(width, height)
794− p.width = width
795− p.height = height
796− var cmds []tea.Cmd
797−
798− if p.session.ID == "" {
799− if p.splashFullScreen {
800− cmds = append(cmds, p.splash.SetSize(width, height))
801− } else {
802− cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
803− cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
804− cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
805− }
806− } else {
807− hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
808− hasQueue := p.promptQueue > 0
809− hasPills := hasIncompleteTodos || hasQueue
810−
811− pillsAreaHeight := 0
812− if hasPills {
813− pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top
814− if p.pillsExpanded {
815− if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos {
816− pillsAreaHeight += len(p.session.Todos)
817− } else if p.focusedPillSection == PillSectionQueue && hasQueue {
818− pillsAreaHeight += p.promptQueue
819− }
820− }
821− }
822−
823− if p.compact {
824− cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight))
825− p.detailsWidth = width - DetailsPositioning
826− cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
827− cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
828− cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
829− } else {
830− cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight))
831− cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
832− cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
833− }
834− cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
835− }
836− return tea.Batch(cmds...)
837−}
838−
839−func (p *chatPage) newSession() tea.Cmd {
840− if p.session.ID == "" {
841− return nil
842− }
843−
844− p.session = session.Session{}
845− p.focusedPane = PanelTypeEditor
846− p.editor.Focus()
847− p.chat.Blur()
848− p.isCanceling = false
849− return tea.Batch(
850− util.CmdHandler(chat.SessionClearedMsg{}),
851− p.SetSize(p.width, p.height),
852− )
853−}
854−
855−func (p *chatPage) setSession(sess session.Session) tea.Cmd {
856− if p.session.ID == sess.ID {
857− return nil
858− }
859−
860− var cmds []tea.Cmd
861− p.session = sess
862−
863− if p.hasInProgressTodo() {
864− cmds = append(cmds, p.todoSpinner.Tick)
865− }
866−
867− cmds = append(cmds, p.SetSize(p.width, p.height))
868− cmds = append(cmds, p.chat.SetSession(sess))
869− cmds = append(cmds, p.sidebar.SetSession(sess))
870− cmds = append(cmds, p.header.SetSession(sess))
871− cmds = append(cmds, p.editor.SetSession(sess))
872−
873− return tea.Sequence(cmds...)
874−}
875−
876−func (p *chatPage) changeFocus() tea.Cmd {
877− if p.session.ID == "" {
878− return nil
879− }
880−
881− switch p.focusedPane {
882− case PanelTypeEditor:
883− p.focusedPane = PanelTypeChat
884− p.chat.Focus()
885− p.editor.Blur()
886− case PanelTypeChat:
887− p.focusedPane = PanelTypeEditor
888− p.editor.Focus()
889− p.chat.Blur()
890− }
891− return nil
892−}
893−
894−func (p *chatPage) togglePillsExpanded() tea.Cmd {
895− hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0
896− if !hasPills {
897− return nil
898− }
899− p.pillsExpanded = !p.pillsExpanded
900− if p.pillsExpanded {
901− if hasIncompleteTodos(p.session.Todos) {
902− p.focusedPillSection = PillSectionTodos
903− } else {
904− p.focusedPillSection = PillSectionQueue
905− }
906− }
907− return p.SetSize(p.width, p.height)
908−}
909−
910−func (p *chatPage) switchPillSection(dir int) tea.Cmd {
911− if !p.pillsExpanded {
912− return nil
913− }
914− hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
915− hasQueue := p.promptQueue > 0
916−
917− if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos {
918− p.focusedPillSection = PillSectionTodos
919− return p.SetSize(p.width, p.height)
920− }
921− if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue {
922− p.focusedPillSection = PillSectionQueue
923− return p.SetSize(p.width, p.height)
924− }
925− return nil
926−}
927−
928−func (p *chatPage) cancel() tea.Cmd {
929− if p.isCanceling {
930− p.isCanceling = false
931− if p.app.AgentCoordinator != nil {
932− p.app.AgentCoordinator.Cancel(p.session.ID)
933− }
934− return nil
935− }
936−
937− if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
938− p.app.AgentCoordinator.ClearQueue(p.session.ID)
939− return nil
940− }
941− p.isCanceling = true
942− return cancelTimerCmd()
943−}
944−
945−func (p *chatPage) setShowDetails(show bool) {
946− p.showingDetails = show
947− p.header.SetDetailsOpen(p.showingDetails)
948− if !p.compact {
949− p.sidebar.SetCompactMode(false)
950− }
951−}
952−
953−func (p *chatPage) toggleDetails() {
954− if p.session.ID == "" || !p.compact {
955− return
956− }
957− p.setShowDetails(!p.showingDetails)
958−}
959−
960−func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
961− session := p.session
962− var cmds []tea.Cmd
963− if p.session.ID == "" {
964− // XXX: The second argument here is the session name, which we leave
965− // blank as it will be auto-generated. Ideally, we remove the need for
966− // that argument entirely.
967− newSession, err := p.app.Sessions.Create(context.Background(), "")
968− if err != nil {
969− return util.ReportError(err)
970− }
971− session = newSession
972− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
973− }
974− if p.app.AgentCoordinator == nil {
975− return util.ReportError(fmt.Errorf("coder agent is not initialized"))
976− }
977− cmds = append(cmds, p.chat.GoToBottom())
978− cmds = append(cmds, func() tea.Msg {
979− _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
980− if err != nil {
981− isCancelErr := errors.Is(err, context.Canceled)
982− isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
983− if isCancelErr || isPermissionErr {
984− return nil
985− }
986− return util.InfoMsg{
987− Type: util.InfoTypeError,
988− Msg: err.Error(),
989− }
990− }
991− return nil
992− })
993− return tea.Batch(cmds...)
994−}
995−
996−func (p *chatPage) Bindings() []key.Binding {
997− bindings := []key.Binding{
998− p.keyMap.NewSession,
999− p.keyMap.AddAttachment,
1000− }
1001− if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1002− cancelBinding := p.keyMap.Cancel
1003− if p.isCanceling {
1004− cancelBinding = key.NewBinding(
1005− key.WithKeys("esc", "alt+esc"),
1006− key.WithHelp("esc", "press again to cancel"),
1007− )
1008− }
1009− bindings = append([]key.Binding{cancelBinding}, bindings...)
1010− }
1011−
1012− switch p.focusedPane {
1013− case PanelTypeChat:
1014− bindings = append([]key.Binding{
1015− key.NewBinding(
1016− key.WithKeys("tab"),
1017− key.WithHelp("tab", "focus editor"),
1018− ),
1019− }, bindings...)
1020− bindings = append(bindings, p.chat.Bindings()...)
1021− case PanelTypeEditor:
1022− bindings = append([]key.Binding{
1023− key.NewBinding(
1024− key.WithKeys("tab"),
1025− key.WithHelp("tab", "focus chat"),
1026− ),
1027− }, bindings...)
1028− bindings = append(bindings, p.editor.Bindings()...)
1029− case PanelTypeSplash:
1030− bindings = append(bindings, p.splash.Bindings()...)
1031− }
1032−
1033− return bindings
1034−}
1035−
1036−func (p *chatPage) Help() help.KeyMap {
1037− var shortList []key.Binding
1038− var fullList [][]key.Binding
1039− switch {
1040− case p.isOnboarding:
1041− switch {
1042− case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1043− shortList = append(shortList,
1044− key.NewBinding(
1045− key.WithKeys("enter"),
1046− key.WithHelp("enter", "copy url & open signup"),
1047− ),
1048− key.NewBinding(
1049− key.WithKeys("c"),
1050− key.WithHelp("c", "copy url"),
1051− ),
1052− )
1053− default:
1054− shortList = append(shortList,
1055− key.NewBinding(
1056− key.WithKeys("enter"),
1057− key.WithHelp("enter", "submit"),
1058− ),
1059− )
1060− }
1061− shortList = append(shortList,
1062− // Quit
1063− key.NewBinding(
1064− key.WithKeys("ctrl+c"),
1065− key.WithHelp("ctrl+c", "quit"),
1066− ),
1067− )
1068− // keep them the same
1069− for _, v := range shortList {
1070− fullList = append(fullList, []key.Binding{v})
1071− }
1072− case p.isOnboarding && !p.splash.IsShowingAPIKey():
1073− shortList = append(shortList,
1074− // Choose model
1075− key.NewBinding(
1076− key.WithKeys("up", "down"),
1077− key.WithHelp("↑/↓", "choose"),
1078− ),
1079− // Accept selection
1080− key.NewBinding(
1081− key.WithKeys("enter", "ctrl+y"),
1082− key.WithHelp("enter", "accept"),
1083− ),
1084− // Quit
1085− key.NewBinding(
1086− key.WithKeys("ctrl+c"),
1087− key.WithHelp("ctrl+c", "quit"),
1088− ),
1089− )
1090− // keep them the same
1091− for _, v := range shortList {
1092− fullList = append(fullList, []key.Binding{v})
1093− }
1094− case p.isOnboarding && p.splash.IsShowingAPIKey():
1095− if p.splash.IsAPIKeyValid() {
1096− shortList = append(shortList,
1097− key.NewBinding(
1098− key.WithKeys("enter"),
1099− key.WithHelp("enter", "continue"),
1100− ),
1101− )
1102− } else {
1103− shortList = append(shortList,
1104− // Go back
1105− key.NewBinding(
1106− key.WithKeys("esc", "alt+esc"),
1107− key.WithHelp("esc", "back"),
1108− ),
1109− )
1110− }
1111− shortList = append(shortList,
1112− // Quit
1113− key.NewBinding(
1114− key.WithKeys("ctrl+c"),
1115− key.WithHelp("ctrl+c", "quit"),
1116− ),
1117− )
1118− // keep them the same
1119− for _, v := range shortList {
1120− fullList = append(fullList, []key.Binding{v})
1121− }
1122− case p.isProjectInit:
1123− shortList = append(shortList,
1124− key.NewBinding(
1125− key.WithKeys("ctrl+c"),
1126− key.WithHelp("ctrl+c", "quit"),
1127− ),
1128− )
1129− // keep them the same
1130− for _, v := range shortList {
1131− fullList = append(fullList, []key.Binding{v})
1132− }
1133− default:
1134− if p.editor.IsCompletionsOpen() {
1135− shortList = append(shortList,
1136− key.NewBinding(
1137− key.WithKeys("tab", "enter"),
1138− key.WithHelp("tab/enter", "complete"),
1139− ),
1140− key.NewBinding(
1141− key.WithKeys("esc", "alt+esc"),
1142− key.WithHelp("esc", "cancel"),
1143− ),
1144− key.NewBinding(
1145− key.WithKeys("up", "down"),
1146− key.WithHelp("↑/↓", "choose"),
1147− ),
1148− )
1149− for _, v := range shortList {
1150− fullList = append(fullList, []key.Binding{v})
1151− }
1152− return core.NewSimpleHelp(shortList, fullList)
1153− }
1154− if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1155− cancelBinding := key.NewBinding(
1156− key.WithKeys("esc", "alt+esc"),
1157− key.WithHelp("esc", "cancel"),
1158− )
1159− if p.isCanceling {
1160− cancelBinding = key.NewBinding(
1161− key.WithKeys("esc", "alt+esc"),
1162− key.WithHelp("esc", "press again to cancel"),
1163− )
1164− }
1165− if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
1166− cancelBinding = key.NewBinding(
1167− key.WithKeys("esc", "alt+esc"),
1168− key.WithHelp("esc", "clear queue"),
1169− )
1170− }
1171− shortList = append(shortList, cancelBinding)
1172− fullList = append(fullList,
1173− []key.Binding{
1174− cancelBinding,
1175− },
1176− )
1177− }
1178− globalBindings := []key.Binding{}
1179− // we are in a session
1180− if p.session.ID != "" {
1181− var tabKey key.Binding
1182− switch p.focusedPane {
1183− case PanelTypeEditor:
1184− tabKey = key.NewBinding(
1185− key.WithKeys("tab"),
1186− key.WithHelp("tab", "focus chat"),
1187− )
1188− case PanelTypeChat:
1189− tabKey = key.NewBinding(
1190− key.WithKeys("tab"),
1191− key.WithHelp("tab", "focus editor"),
1192− )
1193− default:
1194− tabKey = key.NewBinding(
1195− key.WithKeys("tab"),
1196− key.WithHelp("tab", "focus chat"),
1197− )
1198− }
1199− shortList = append(shortList, tabKey)
1200− globalBindings = append(globalBindings, tabKey)
1201−
1202− // Show left/right to switch sections when expanded and both exist
1203− hasTodos := hasIncompleteTodos(p.session.Todos)
1204− hasQueue := p.promptQueue > 0
1205− if p.pillsExpanded && hasTodos && hasQueue {
1206− shortList = append(shortList, p.keyMap.PillLeft)
1207− globalBindings = append(globalBindings, p.keyMap.PillLeft)
1208− }
1209− }
1210− commandsBinding := key.NewBinding(
1211− key.WithKeys("ctrl+p"),
1212− key.WithHelp("ctrl+p", "commands"),
1213− )
1214− if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1215− commandsBinding.SetHelp("/ or ctrl+p", "commands")
1216− }
1217− modelsBinding := key.NewBinding(
1218− key.WithKeys("ctrl+m", "ctrl+l"),
1219− key.WithHelp("ctrl+l", "models"),
1220− )
1221− if p.keyboardEnhancements.Flags > 0 {
1222− // non-zero flags mean we have at least key disambiguation
1223− modelsBinding.SetHelp("ctrl+m", "models")
1224− }
1225− helpBinding := key.NewBinding(
1226− key.WithKeys("ctrl+g"),
1227− key.WithHelp("ctrl+g", "more"),
1228− )
1229− globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1230− globalBindings = append(globalBindings,
1231− key.NewBinding(
1232− key.WithKeys("ctrl+s"),
1233− key.WithHelp("ctrl+s", "sessions"),
1234− ),
1235− )
1236− if p.session.ID != "" {
1237− globalBindings = append(globalBindings,
1238− key.NewBinding(
1239− key.WithKeys("ctrl+n"),
1240− key.WithHelp("ctrl+n", "new sessions"),
1241− ))
1242− }
1243− shortList = append(shortList,
1244− // Commands
1245− commandsBinding,
1246− modelsBinding,
1247− )
1248− fullList = append(fullList, globalBindings)
1249−
1250− switch p.focusedPane {
1251− case PanelTypeChat:
1252− shortList = append(shortList,
1253− key.NewBinding(
1254− key.WithKeys("up", "down"),
1255− key.WithHelp("↑↓", "scroll"),
1256− ),
1257− messages.CopyKey,
1258− )
1259− fullList = append(fullList,
1260− []key.Binding{
1261− key.NewBinding(
1262− key.WithKeys("up", "down"),
1263− key.WithHelp("↑↓", "scroll"),
1264− ),
1265− key.NewBinding(
1266− key.WithKeys("shift+up", "shift+down"),
1267− key.WithHelp("shift+↑↓", "next/prev item"),
1268− ),
1269− key.NewBinding(
1270− key.WithKeys("pgup", "b"),
1271− key.WithHelp("b/pgup", "page up"),
1272− ),
1273− key.NewBinding(
1274− key.WithKeys("pgdown", " ", "f"),
1275− key.WithHelp("f/pgdn", "page down"),
1276− ),
1277− },
1278− []key.Binding{
1279− key.NewBinding(
1280− key.WithKeys("u"),
1281− key.WithHelp("u", "half page up"),
1282− ),
1283− key.NewBinding(
1284− key.WithKeys("d"),
1285− key.WithHelp("d", "half page down"),
1286− ),
1287− key.NewBinding(
1288− key.WithKeys("g", "home"),
1289− key.WithHelp("g", "home"),
1290− ),
1291− key.NewBinding(
1292− key.WithKeys("G", "end"),
1293− key.WithHelp("G", "end"),
1294− ),
1295− },
1296− []key.Binding{
1297− messages.CopyKey,
1298− messages.ClearSelectionKey,
1299− },
1300− )
1301− case PanelTypeEditor:
1302− newLineBinding := key.NewBinding(
1303− key.WithKeys("shift+enter", "ctrl+j"),
1304− // "ctrl+j" is a common keybinding for newline in many editors. If
1305− // the terminal supports "shift+enter", we substitute the help text
1306− // to reflect that.
1307− key.WithHelp("ctrl+j", "newline"),
1308− )
1309− if p.keyboardEnhancements.Flags > 0 {
1310− // Non-zero flags mean we have at least key disambiguation.
1311− newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1312− }
1313− shortList = append(shortList, newLineBinding)
1314− fullList = append(fullList,
1315− []key.Binding{
1316− newLineBinding,
1317− key.NewBinding(
1318− key.WithKeys("ctrl+f"),
1319− key.WithHelp("ctrl+f", "add image"),
1320− ),
1321− key.NewBinding(
1322− key.WithKeys("@"),
1323− key.WithHelp("@", "mention file"),
1324− ),
1325− key.NewBinding(
1326− key.WithKeys("ctrl+o"),
1327− key.WithHelp("ctrl+o", "open editor"),
1328− ),
1329− })
1330−
1331− if p.editor.HasAttachments() {
1332− fullList = append(fullList, []key.Binding{
1333− key.NewBinding(
1334− key.WithKeys("ctrl+r"),
1335− key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1336− ),
1337− key.NewBinding(
1338− key.WithKeys("ctrl+r", "r"),
1339− key.WithHelp("ctrl+r+r", "delete all attachments"),
1340− ),
1341− key.NewBinding(
1342− key.WithKeys("esc", "alt+esc"),
1343− key.WithHelp("esc", "cancel delete mode"),
1344− ),
1345− })
1346− }
1347− }
1348− shortList = append(shortList,
1349− // Quit
1350− key.NewBinding(
1351− key.WithKeys("ctrl+c"),
1352− key.WithHelp("ctrl+c", "quit"),
1353− ),
1354− // Help
1355− helpBinding,
1356− )
1357− fullList = append(fullList, []key.Binding{
1358− key.NewBinding(
1359− key.WithKeys("ctrl+g"),
1360− key.WithHelp("ctrl+g", "less"),
1361− ),
1362− })
1363− }
1364−
1365− return core.NewSimpleHelp(shortList, fullList)
1366−}
1367−
1368−func (p *chatPage) IsChatFocused() bool {
1369− return p.focusedPane == PanelTypeChat
1370−}
1371−
1372−// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1373−// Returns true if the mouse is over the chat area, false otherwise.
1374−func (p *chatPage) isMouseOverChat(x, y int) bool {
1375− // No session means no chat area
1376− if p.session.ID == "" {
1377− return false
1378− }
1379−
1380− var chatX, chatY, chatWidth, chatHeight int
1381−
1382− if p.compact {
1383− // In compact mode: chat area starts after header and spans full width
1384− chatX = 0
1385− chatY = HeaderHeight
1386− chatWidth = p.width
1387− chatHeight = p.height - EditorHeight - HeaderHeight
1388− } else {
1389− // In non-compact mode: chat area spans from left edge to sidebar
1390− chatX = 0
1391− chatY = 0
1392− chatWidth = p.width - SideBarWidth
1393− chatHeight = p.height - EditorHeight
1394− }
1395−
1396− // Check if mouse coordinates are within chat bounds
1397− return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1398−}
1399−
1400−func (p *chatPage) hasInProgressTodo() bool {
1401− for _, todo := range p.session.Todos {
1402− if todo.Status == session.TodoStatusInProgress {
1403− return true
1404− }
1405− }
1406− return false
1407−}
internal/tui/page/chat/keys.godeleted−53
@@ -1,53 +0,0 @@
1−package chat
2−
3−import (
4− "charm.land/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− NewSession key.Binding
9− AddAttachment key.Binding
10− Cancel key.Binding
11− Tab key.Binding
12− Details key.Binding
13− TogglePills key.Binding
14− PillLeft key.Binding
15− PillRight key.Binding
16−}
17−
18−func DefaultKeyMap() KeyMap {
19− return KeyMap{
20− NewSession: key.NewBinding(
21− key.WithKeys("ctrl+n"),
22− key.WithHelp("ctrl+n", "new session"),
23− ),
24− AddAttachment: key.NewBinding(
25− key.WithKeys("ctrl+f"),
26− key.WithHelp("ctrl+f", "add attachment"),
27− ),
28− Cancel: key.NewBinding(
29− key.WithKeys("esc", "alt+esc"),
30− key.WithHelp("esc", "cancel"),
31− ),
32− Tab: key.NewBinding(
33− key.WithKeys("tab"),
34− key.WithHelp("tab", "change focus"),
35− ),
36− Details: key.NewBinding(
37− key.WithKeys("ctrl+d"),
38− key.WithHelp("ctrl+d", "toggle details"),
39− ),
40− TogglePills: key.NewBinding(
41− key.WithKeys("ctrl+space"),
42− key.WithHelp("ctrl+space", "toggle tasks"),
43− ),
44− PillLeft: key.NewBinding(
45− key.WithKeys("left"),
46− key.WithHelp("←/→", "switch section"),
47− ),
48− PillRight: key.NewBinding(
49− key.WithKeys("right"),
50− key.WithHelp("←/→", "switch section"),
51− ),
52− }
53−}
internal/tui/page/chat/pills.godeleted−125
@@ -1,125 +0,0 @@
1−package chat
2−
3−import (
4− "fmt"
5− "strings"
6−
7− "charm.land/lipgloss/v2"
8− "github.com/charmbracelet/crush/internal/session"
9− "github.com/charmbracelet/crush/internal/tui/components/chat/todos"
10− "github.com/charmbracelet/crush/internal/tui/styles"
11−)
12−
13−func hasIncompleteTodos(todos []session.Todo) bool {
14− for _, todo := range todos {
15− if todo.Status != session.TodoStatusCompleted {
16− return true
17− }
18− }
19− return false
20−}
21−
22−const (
23− pillHeightWithBorder = 3
24− maxTaskDisplayLength = 40
25− maxQueueDisplayLength = 60
26−)
27−
28−func queuePill(queue int, focused, pillsPanelFocused bool, t *styles.Theme) string {
29− if queue <= 0 {
30− return ""
31− }
32− triangles := styles.ForegroundGrad("▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Accent)
33− if queue < 10 {
34− triangles = triangles[:queue]
35− }
36−
37− content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
38−
39− style := t.S().Base.PaddingLeft(1).PaddingRight(1)
40− if !pillsPanelFocused || focused {
41− style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay)
42− } else {
43− style = style.BorderStyle(lipgloss.HiddenBorder())
44− }
45− return style.Render(content)
46−}
47−
48−func todoPill(todos []session.Todo, spinnerView string, focused, pillsPanelFocused bool, t *styles.Theme) string {
49− if !hasIncompleteTodos(todos) {
50− return ""
51− }
52−
53− completed := 0
54− var currentTodo *session.Todo
55− for i := range todos {
56− switch todos[i].Status {
57− case session.TodoStatusCompleted:
58− completed++
59− case session.TodoStatusInProgress:
60− if currentTodo == nil {
61− currentTodo = &todos[i]
62− }
63− }
64− }
65−
66− total := len(todos)
67−
68− label := "To-Do"
69− progress := t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("%d/%d", completed, total))
70−
71− var content string
72− if pillsPanelFocused {
73− content = fmt.Sprintf("%s %s", label, progress)
74− } else if currentTodo != nil {
75− taskText := currentTodo.Content
76− if currentTodo.ActiveForm != "" {
77− taskText = currentTodo.ActiveForm
78− }
79− if len(taskText) > maxTaskDisplayLength {
80− taskText = taskText[:maxTaskDisplayLength-1] + "…"
81− }
82− task := t.S().Base.Foreground(t.FgSubtle).Render(taskText)
83− content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task)
84− } else {
85− content = fmt.Sprintf("%s %s", label, progress)
86− }
87−
88− style := t.S().Base.PaddingLeft(1).PaddingRight(1)
89− if !pillsPanelFocused || focused {
90− style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay)
91− } else {
92− style = style.BorderStyle(lipgloss.HiddenBorder())
93− }
94− return style.Render(content)
95−}
96−
97−func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Theme, width int) string {
98− return todos.FormatTodosList(sessionTodos, spinnerView, t, width)
99−}
100−
101−func queueList(queueItems []string, t *styles.Theme) string {
102− if len(queueItems) == 0 {
103− return ""
104− }
105−
106− var lines []string
107− for _, item := range queueItems {
108− text := item
109− if len(text) > maxQueueDisplayLength {
110− text = text[:maxQueueDisplayLength-1] + "…"
111− }
112− prefix := t.S().Base.Foreground(t.FgMuted).Render(" •") + " "
113− lines = append(lines, prefix+t.S().Base.Foreground(t.FgMuted).Render(text))
114− }
115−
116− return strings.Join(lines, "\n")
117−}
118−
119−func sectionLine(availableWidth int, t *styles.Theme) string {
120− if availableWidth <= 0 {
121− return ""
122− }
123− line := strings.Repeat("─", availableWidth)
124− return t.S().Base.Foreground(t.Border).Render(line)
125−}
internal/tui/page/page.godeleted−8
@@ -1,8 +0,0 @@
1−package page
2−
3−type PageID string
4−
5−// PageChangeMsg is used to change the current page
6−type PageChangeMsg struct {
7− ID PageID
8−}
internal/tui/styles/charmtone.godeleted−83
@@ -1,83 +0,0 @@
1−package styles
2−
3−import (
4− "charm.land/lipgloss/v2"
5− "github.com/charmbracelet/x/exp/charmtone"
6−)
7−
8−func NewCharmtoneTheme() *Theme {
9− t := &Theme{
10− Name: "charmtone",
11− IsDark: true,
12−
13− Primary: charmtone.Charple,
14− Secondary: charmtone.Dolly,
15− Tertiary: charmtone.Bok,
16− Accent: charmtone.Zest,
17−
18− // Backgrounds
19− BgBase: charmtone.Pepper,
20− BgBaseLighter: charmtone.BBQ,
21− BgSubtle: charmtone.Charcoal,
22− BgOverlay: charmtone.Iron,
23−
24− // Foregrounds
25− FgBase: charmtone.Ash,
26− FgMuted: charmtone.Squid,
27− FgHalfMuted: charmtone.Smoke,
28− FgSubtle: charmtone.Oyster,
29− FgSelected: charmtone.Salt,
30−
31− // Borders
32− Border: charmtone.Charcoal,
33− BorderFocus: charmtone.Charple,
34−
35− // Status
36− Success: charmtone.Guac,
37− Error: charmtone.Sriracha,
38− Warning: charmtone.Zest,
39− Info: charmtone.Malibu,
40−
41− // Colors
42− White: charmtone.Butter,
43−
44− BlueLight: charmtone.Sardine,
45− BlueDark: charmtone.Damson,
46− Blue: charmtone.Malibu,
47−
48− Yellow: charmtone.Mustard,
49− Citron: charmtone.Citron,
50−
51− Green: charmtone.Julep,
52− GreenDark: charmtone.Guac,
53− GreenLight: charmtone.Bok,
54−
55− Red: charmtone.Coral,
56− RedDark: charmtone.Sriracha,
57− RedLight: charmtone.Salmon,
58− Cherry: charmtone.Cherry,
59− }
60−
61− // Text selection.
62− t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
63−
64− // LSP and MCP status.
65− t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
66− t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
67− t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
68− t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
69−
70− // Editor: Yolo Mode.
71− t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
72− t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
73− t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
74− t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid)
75−
76− // oAuth Chooser.
77− t.AuthBorderSelected = lipgloss.NewStyle().BorderForeground(charmtone.Guac)
78− t.AuthTextSelected = lipgloss.NewStyle().Foreground(charmtone.Julep)
79− t.AuthBorderUnselected = lipgloss.NewStyle().BorderForeground(charmtone.Iron)
80− t.AuthTextUnselected = lipgloss.NewStyle().Foreground(charmtone.Squid)
81−
82− return t
83−}
internal/tui/styles/chroma.godeleted−79

This diff is too big to show here.

internal/tui/styles/icons.godeleted−48

This diff is too big to show here.

internal/tui/styles/markdown.godeleted−205

This diff is too big to show here.

internal/tui/styles/theme.godeleted−709

This diff is too big to show here.

internal/tui/tui.godeleted−712

This diff is too big to show here.

internal/tui/util/shell.godeleted−15

This diff is too big to show here.

internal/tui/util/util.godeleted−45

This diff is too big to show here.

internal/ui/common/common.go+2−2

This diff is too big to show here.

internal/ui/common/diff.go+1−1

This diff is too big to show here.

internal/ui/dialog/actions.go+7−7

This diff is too big to show here.

internal/ui/dialog/api_key_input.go+2−2

This diff is too big to show here.

internal/ui/dialog/arguments.go+2−2

This diff is too big to show here.

internal/ui/dialog/models.go+2−2

This diff is too big to show here.

internal/ui/dialog/oauth.go+5−5

This diff is too big to show here.

internal/ui/dialog/sessions.go+4−4

This diff is too big to show here.

internal/tui/exp/diffview/Taskfile.yaml → internal/ui/diffview/Taskfile.yamlrenamed

The content is the same.

internal/tui/exp/diffview/chroma.go → internal/ui/diffview/chroma.gorenamed

The content is the same.

internal/tui/exp/diffview/diffview.go → internal/ui/diffview/diffview.gorenamed

The content is the same.

internal/tui/exp/diffview/diffview_test.go → internal/ui/diffview/diffview_test.gorenamed+1−1

This diff is too big to show here.

internal/tui/exp/diffview/split.go → internal/ui/diffview/split.gorenamed

The content is the same.

internal/tui/exp/diffview/style.go → internal/ui/diffview/style.gorenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDefault.after → internal/ui/diffview/testdata/TestDefault.afterrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDefault.before → internal/ui/diffview/testdata/TestDefault.beforerenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/CustomContextLines/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/Default/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/Default/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/Default/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/LargeWidth/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/MultipleHunks/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/Narrow/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/Narrow/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/Narrow/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/NoLineNumbers/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/NoSyntaxHighlight/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Split/SmallWidth/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/CustomContextLines/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/Default/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/Default/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/Default/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/LargeWidth/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/MultipleHunks/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/Narrow/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/NoLineNumbers/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/NoSyntaxHighlight/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/DarkMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.golden → internal/ui/diffview/testdata/TestDiffView/Unified/SmallWidth/LightMode.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf001.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf002.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf003.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf004.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf005.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf006.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf007.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf008.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf009.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf010.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf011.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf012.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf013.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf014.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf015.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf016.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf017.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf018.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf019.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Split/HeightOf020.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf001.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf002.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf003.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf004.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf005.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf006.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf007.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf008.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf009.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf010.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf011.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf012.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf013.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf014.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf015.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf016.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf017.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf018.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf019.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.golden → internal/ui/diffview/testdata/TestDiffViewHeight/Unified/HeightOf020.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden → internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Split.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden → internal/ui/diffview/testdata/TestDiffViewLineBreakIssue/Unified.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewTabs/Split.golden → internal/ui/diffview/testdata/TestDiffViewTabs/Split.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewTabs/Unified.golden → internal/ui/diffview/testdata/TestDiffViewTabs/Unified.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf001.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf002.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf003.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf004.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf005.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf006.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf007.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf008.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf009.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf010.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf011.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf012.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf013.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf014.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf015.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf016.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf017.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf018.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf019.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf020.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf021.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf022.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf023.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf024.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf025.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf026.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf027.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf028.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf029.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf030.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf031.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf032.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf033.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf034.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf035.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf036.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf037.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf038.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf039.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf040.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf041.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf042.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf043.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf044.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf045.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf046.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf047.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf048.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf049.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf050.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf051.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf052.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf053.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf054.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf055.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf056.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf057.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf058.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf059.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf060.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf061.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf062.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf063.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf064.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf065.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf066.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf067.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf068.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf069.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf070.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf071.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf072.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf073.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf074.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf075.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf076.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf077.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf078.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf079.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf080.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf081.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf082.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf083.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf084.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf085.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf086.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf087.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf088.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf089.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf090.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf091.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf092.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf093.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf094.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf095.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf096.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf097.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf098.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf099.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf100.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf101.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf102.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf103.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf104.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf105.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf106.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf107.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf108.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf109.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Split/WidthOf110.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf001.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf002.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf003.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf004.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf005.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf006.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf007.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf008.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf009.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf010.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf011.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf012.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf013.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf014.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf015.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf016.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf017.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf018.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf019.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf020.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf021.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf022.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf023.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf024.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf025.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf026.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf027.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf028.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf029.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf030.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf031.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf032.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf033.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf034.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf035.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf036.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf037.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf038.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf039.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf040.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf041.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf042.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf043.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf044.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf045.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf046.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf047.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf048.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf049.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf050.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf051.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf052.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf053.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf054.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf055.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf056.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf057.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf058.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf059.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.golden → internal/ui/diffview/testdata/TestDiffViewWidth/Unified/WidthOf060.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf00.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf01.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf02.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf03.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf04.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf05.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf06.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf07.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf08.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf09.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf10.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf11.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf12.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf13.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf14.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf15.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf16.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf17.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf18.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf19.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Split/XOffsetOf20.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf00.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden → internal/ui/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf00.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf01.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf02.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf03.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf04.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf05.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf06.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf07.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf08.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf09.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf10.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf00.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf01.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf02.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf03.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf04.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf05.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf06.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf07.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf08.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf09.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf10.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf11.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden → internal/ui/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestLineBreakIssue.after → internal/ui/diffview/testdata/TestLineBreakIssue.afterrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestLineBreakIssue.before → internal/ui/diffview/testdata/TestLineBreakIssue.beforerenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestMultipleHunks.after → internal/ui/diffview/testdata/TestMultipleHunks.afterrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestMultipleHunks.before → internal/ui/diffview/testdata/TestMultipleHunks.beforerenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestNarrow.after → internal/ui/diffview/testdata/TestNarrow.afterrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestNarrow.before → internal/ui/diffview/testdata/TestNarrow.beforerenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestTabs.after → internal/ui/diffview/testdata/TestTabs.afterrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestTabs.before → internal/ui/diffview/testdata/TestTabs.beforerenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/Content.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLines/JSON.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/Content.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusOne/JSON.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/Content.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.golden → internal/ui/diffview/testdata/TestUdiff/ToUnifiedDiff/DefaultContextLinesPlusTwo/JSON.goldenrenamed

The content is the same.

internal/tui/exp/diffview/testdata/TestUdiff/Unified.golden → internal/ui/diffview/testdata/TestUdiff/Unified.goldenrenamed

The content is the same.

internal/tui/exp/diffview/udiff_test.go → internal/ui/diffview/udiff_test.gorenamed

The content is the same.

internal/tui/exp/diffview/util.go → internal/ui/diffview/util.gorenamed

The content is the same.

internal/tui/exp/diffview/util_test.go → internal/ui/diffview/util_test.gorenamed

The content is the same.

internal/ui/image/image.go+3−3

This diff is too big to show here.

internal/ui/logo/logo.go+7−8

This diff is too big to show here.

internal/ui/model/filter.gonew file+22

This diff is too big to show here.

internal/ui/model/onboarding.go+2−2

This diff is too big to show here.

internal/ui/model/session.go+3−3

This diff is too big to show here.

internal/ui/model/sidebar.go+1−1

This diff is too big to show here.

internal/ui/model/status.go+10−10

This diff is too big to show here.

internal/ui/model/ui.go+52−52

This diff is too big to show here.

internal/ui/styles/styles.go+1−1

This diff is too big to show here.

internal/uiutil/uiutil.go → internal/ui/util/util.gorenamed+2−4

This diff is too big to show here.

internal/uicmd/uicmd.godeleted−314

This diff is too big to show here.