@@ -19,6 +19,7 @@ import (
1919 "github.com/toeirei/keymaster/internal/model"
2020 "github.com/toeirei/keymaster/internal/state"
2121 "github.com/toeirei/keymaster/internal/ui"
22+ "golang.org/x/term"
2223)
2324
2425type deployState int
@@ -83,13 +84,25 @@ type deployModel struct {
8384func newDeployModelWithSearcher (s ui.AccountSearcher ) deployModel {
8485 pi := newPassphraseInput ()
8586 fi := newFilenameInput ()
86- return deployModel {
87+ m := deployModel {
8788 state : deployStateMenu ,
8889 fleetResults : make (map [int ]error ),
8990 passphraseInput : pi ,
9091 filenameInput : fi ,
9192 searcher : s ,
9293 }
94+
95+ // Try to initialize with the current terminal size so the view fills
96+ // the available area on first render. Fall back to 80x24 on error.
97+ if w , h , err := term .GetSize (int (os .Stdout .Fd ())); err == nil {
98+ m .width = w
99+ m .height = h
100+ } else {
101+ m .width = 80
102+ m .height = 24
103+ }
104+
105+ return m
93106}
94107
95108// newDeployModel is a convenience wrapper that uses the package default searcher.
@@ -125,6 +138,11 @@ func newFilenameInput() textinput.Model {
125138
126139// Update handles messages and updates the deploy model's state.
127140func (m deployModel ) Update (msg tea.Msg ) (tea.Model , tea.Cmd ) {
141+ // Update model size when terminal is resized so the view can reflow.
142+ if sizeMsg , ok := msg .(tea.WindowSizeMsg ); ok {
143+ m .width = sizeMsg .Width
144+ m .height = sizeMsg .Height
145+ }
128146 switch m .state {
129147 case deployStateMenu :
130148 return m .updateMenu (msg )
@@ -232,15 +250,15 @@ func (m deployModel) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
232250 case "up" , "k" :
233251 if m .menuCursor > 0 {
234252 m .menuCursor --
235- m . hasInteracted = true
253+ // no-op: do not track hasInteracted for deploy view
236254 }
237255 case "down" , "j" :
238256 if m .menuCursor < 3 { // There are 4 menu items (0-3)
239257 m .menuCursor ++
240- m . hasInteracted = true
258+ // no-op: do not track hasInteracted for deploy view
241259 }
242260 case "enter" :
243- m . hasInteracted = true
261+ // no-op: do not track hasInteracted for deploy view
244262 switch m .menuCursor {
245263 case 0 : // Deploy to Fleet (fully automatic)
246264 m .wasFleetDeploy = true
@@ -454,15 +472,15 @@ func (m deployModel) updateSelectTag(msg tea.Msg) (tea.Model, tea.Cmd) {
454472 case "up" , "k" :
455473 if m .tagCursor > 0 {
456474 m .tagCursor --
457- m . hasInteracted = true
475+ // no-op: do not track hasInteracted for deploy view
458476 }
459477 case "down" , "j" :
460478 if m .tagCursor < len (m .tags )- 1 {
461479 m .tagCursor ++
462- m . hasInteracted = true
480+ // no-op: do not track hasInteracted for deploy view
463481 }
464482 case "enter" :
465- m . hasInteracted = true
483+ // no-op: do not track hasInteracted for deploy view
466484 if len (m .tags ) == 0 {
467485 return m , nil
468486 }
@@ -515,11 +533,11 @@ func (m deployModel) updateShowAuthorizedKeys(msg tea.Msg) (tea.Model, tea.Cmd)
515533 m .status = i18n .T ("deploy.status.copy_failed" , err .Error ())
516534 } else {
517535 m .status = i18n .T ("deploy.status.copy_success" )
518- m . hasInteracted = true
536+ // no-op: do not track hasInteracted for deploy view
519537 }
520538 return m , nil
521539 case "s" :
522- m . hasInteracted = true
540+ // no-op: do not track hasInteracted for deploy view
523541 m .state = deployStateEnterFilename
524542 m .filenameInput .Focus ()
525543 m .status = ""
@@ -590,12 +608,16 @@ func (m deployModel) View() string {
590608
591609 paneStyle := lipgloss .NewStyle ().Border (lipgloss .RoundedBorder ()).BorderForeground (colorSubtle ).Padding (1 , 2 )
592610 helpFooterStyle := lipgloss .NewStyle ().Foreground (lipgloss .Color ("241" )).Background (lipgloss .Color ("236" )).Padding (0 , 1 ).Italic (true )
611+ paneWidth := m .width
612+ if paneWidth <= 0 {
613+ paneWidth = 80
614+ }
593615
594616 if m .err != nil {
595617 title := titleStyle .Render (i18n .T ("deploy.failed" ))
596618 help := helpFooterStyle .Render (i18n .T ("deploy.help_failed" ))
597619 content := fmt .Sprintf (i18n .T ("account_form.error" ), m .err )
598- mainPane := paneStyle .Width (60 ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , content ))
620+ mainPane := paneStyle .Width (paneWidth ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , content ))
599621 return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , help )
600622 }
601623
@@ -612,14 +634,16 @@ func (m deployModel) View() string {
612634 listItems = append (listItems , itemStyle .Render (" " + label ))
613635 }
614636 }
615- mainPane := paneStyle .Width (60 ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , listItems ... )))
616- // Use AlignFooter for consistent right-aligned layout and honor initial hint
617- var help string
618- if ! m .hasInteracted {
619- help = helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.footer_empty" ), "" , m .width ))
620- } else {
621- help = helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.help_menu" ), "" , m .width ))
637+ footerStr := m .footerFor (paneWidth )
638+ headerHeight := lipgloss .Height (title )
639+ footerHeight := lipgloss .Height (footerStr )
640+ paneHeight := m .height - headerHeight - footerHeight - 2
641+ if paneHeight <= 2 {
642+ paneHeight = 3
622643 }
644+ mainPane := paneStyle .Width (paneWidth ).Height (paneHeight ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , listItems ... )))
645+ // Centralized footer for consistent right-aligned layout
646+ help := footerStr
623647 if m .status != "" {
624648 mainPane += "\n " + helpFooterStyle .Render (m .status )
625649 }
@@ -644,22 +668,15 @@ func (m deployModel) View() string {
644668 }
645669 }
646670 }
647- mainPane := paneStyle .Width (60 ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , listItems ... )))
648- var filterStatus string
649- if m .isFilteringAccount {
650- filterStatus = i18n .T ("deploy.filtering" , m .accountFilter )
651- } else if m .accountFilter != "" {
652- filterStatus = i18n .T ("deploy.filter_active" , m .accountFilter )
653- } else {
654- filterStatus = i18n .T ("deploy.filter_hint" )
655- }
656- left := i18n .T ("deploy.help_select" )
657- help := helpFooterStyle .Render (AlignFooter (left , filterStatus , m .width ))
658-
659- // If the user has not interacted yet, show a compact initial hint instead
660- if ! m .hasInteracted {
661- return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.footer_empty" ), "" , m .width )))
671+ footerStr := m .footerFor (paneWidth )
672+ headerHeight := lipgloss .Height (title )
673+ footerHeight := lipgloss .Height (footerStr )
674+ paneHeight := m .height - headerHeight - footerHeight - 2
675+ if paneHeight <= 2 {
676+ paneHeight = 3
662677 }
678+ mainPane := paneStyle .Width (paneWidth ).Height (paneHeight ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , listItems ... )))
679+ help := footerStr
663680 return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , help )
664681
665682 case deployStateSelectTag :
@@ -676,15 +693,16 @@ func (m deployModel) View() string {
676693 }
677694 }
678695 }
679- mainPane := paneStyle .Width (60 ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , listItems ... )))
680- // Show compact initial hint until user interacts
681- var h string
682- if ! m .hasInteracted {
683- h = helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.footer_empty" ), "" , m .width ))
684- } else {
685- h = helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.help_select" ), "" , m .width ))
696+ footerStr := m .footerFor (paneWidth )
697+ headerHeight := lipgloss .Height (title )
698+ footerHeight := lipgloss .Height (footerStr )
699+ paneHeight := m .height - headerHeight - footerHeight - 2
700+ if paneHeight <= 2 {
701+ paneHeight = 3
686702 }
687- return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , h )
703+ mainPane := paneStyle .Width (paneWidth ).Height (paneHeight ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , listItems ... )))
704+ help := footerStr
705+ return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , help )
688706
689707 case deployStateShowAuthorizedKeys :
690708 // Render just the keys for easy copy-pasting, with a title and help outside the main content.
@@ -693,14 +711,17 @@ func (m deployModel) View() string {
693711 if m .status != "" {
694712 content = append (content , statusMessageStyle .Render (m .status ), "" )
695713 }
696- mainPane := lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , content ... ), m .authorizedKeys )
697- var h string
698- if ! m .hasInteracted {
699- h = helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.footer_empty" ), "" , m .width ))
700- } else {
701- h = helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.help_keys" ), "" , m .width ))
714+ contentStr := lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , content ... ), m .authorizedKeys )
715+ footerStr := m .footerFor (paneWidth )
716+ headerHeight := lipgloss .Height (title )
717+ footerHeight := lipgloss .Height (footerStr )
718+ paneHeight := m .height - headerHeight - footerHeight - 2
719+ if paneHeight <= 2 {
720+ paneHeight = 3
702721 }
703- return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , h )
722+ mainPane := paneStyle .Width (paneWidth ).Height (paneHeight ).Render (contentStr )
723+ help := footerStr
724+ return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , help )
704725
705726 case deployStateEnterFilename :
706727 var b strings.Builder
@@ -735,8 +756,15 @@ func (m deployModel) View() string {
735756 }
736757 statusLines = append (statusLines , fmt .Sprintf (" %s %s" , acc .String (), status ))
737758 }
738- mainPane := paneStyle .Width (60 ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , statusLines ... )))
739- help := helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.help_wait" ), "" , m .width ))
759+ footerStr := m .footerFor (paneWidth )
760+ headerHeight := lipgloss .Height (title )
761+ footerHeight := lipgloss .Height (footerStr )
762+ paneHeight := m .height - headerHeight - footerHeight - 2
763+ if paneHeight <= 2 {
764+ paneHeight = 3
765+ }
766+ mainPane := paneStyle .Width (paneWidth ).Height (paneHeight ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , lipgloss .JoinVertical (lipgloss .Left , statusLines ... )))
767+ help := footerStr
740768 if m .status != "" {
741769 mainPane += "\n " + helpFooterStyle .Render (m .status )
742770 }
@@ -762,7 +790,14 @@ func (m deployModel) View() string {
762790
763791 case deployStateInProgress :
764792 title := titleStyle .Render (i18n .T ("deploy.deploying" ))
765- mainPane := paneStyle .Width (60 ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , m .status ))
793+ footerStr := m .footerFor (paneWidth )
794+ headerHeight := lipgloss .Height (title )
795+ footerHeight := lipgloss .Height (footerStr )
796+ paneHeight := m .height - headerHeight - footerHeight - 2
797+ if paneHeight <= 2 {
798+ paneHeight = 3
799+ }
800+ mainPane := paneStyle .Width (paneWidth ).Height (paneHeight ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , m .status ))
766801 return lipgloss .JoinVertical (lipgloss .Left , mainPane )
767802
768803 case deployStateComplete :
@@ -791,16 +826,53 @@ func (m deployModel) View() string {
791826 }
792827
793828 resultBlock := renderResultBlock (primary , warnings , m .err )
794- mainPane := paneStyle .Width (60 ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , resultBlock ))
795- var h string
796- if ! m .hasInteracted {
797- h = helpFooterStyle .Render (AlignFooter (i18n .T ("deploy.footer_empty" ), "" , m .width ))
829+ mainPane := paneStyle .Width (paneWidth ).Render (lipgloss .JoinVertical (lipgloss .Left , title , "" , resultBlock ))
830+ help := m .footerFor (paneWidth )
831+ return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , help )
832+ }
833+ return ""
834+ }
835+
836+ // footerFor builds the standardized footer for the deploy view based on
837+ // the current state and filter flags. It returns a fully styled string.
838+ func (m deployModel ) footerFor (width int ) string {
839+ footerStyle := lipgloss .NewStyle ().Foreground (lipgloss .Color ("241" )).Background (lipgloss .Color ("236" )).Padding (0 , 1 ).Italic (true )
840+
841+ // Default tokens
842+ left := ""
843+ right := ""
844+
845+ switch m .state {
846+ case deployStateMenu :
847+ left = i18n .T ("deploy.help_menu" )
848+ case deployStateSelectAccount :
849+ left = i18n .T ("deploy.help_select" )
850+ if m .isFilteringAccount {
851+ right = i18n .T ("deploy.filtering" , m .accountFilter )
852+ } else if m .accountFilter != "" {
853+ right = i18n .T ("deploy.filter_active" , m .accountFilter )
798854 } else {
799- h = helpFooterStyle . Render ( AlignFooter ( i18n .T ("deploy.help_complete" ), "" , m . width ) )
855+ right = i18n .T ("deploy.filter_hint" )
800856 }
801- return lipgloss .JoinVertical (lipgloss .Left , mainPane , "" , h )
857+ case deployStateSelectTag :
858+ left = i18n .T ("deploy.help_select" )
859+ case deployStateShowAuthorizedKeys :
860+ left = i18n .T ("deploy.help_keys" )
861+ case deployStateFleetInProgress :
862+ left = i18n .T ("deploy.help_wait" )
863+ case deployStateEnterPassphrase :
864+ left = i18n .T ("deploy.passphrase_help" )
865+ case deployStateEnterFilename :
866+ left = i18n .T ("deploy.help_enter_filename" )
867+ case deployStateInProgress :
868+ left = ""
869+ case deployStateComplete :
870+ left = i18n .T ("deploy.help_complete" )
871+ default :
872+ left = i18n .T ("deploy.help_menu" )
802873 }
803- return ""
874+
875+ return footerStyle .Render (AlignFooter (left , right , width ))
804876}
805877
806878// performDeploymentCmd is a tea.Cmd that executes the full deployment logic for a single account.
0 commit comments