fix: banking shell screen rendering artifacts and transfer panic

Fix rendering issues where content from previous screens bled through
when switching between views of different heights/widths:

- Pad every line to full terminal width (ANSI-aware) so shorter lines
  overwrite leftover content from previous renders
- Track terminal height via WindowSizeMsg and pad between content and
  footer to fill the screen
- Send tea.ClearScreen on all screen transitions for height changes
- Fix panic in transfer completion when routing number is < 4 chars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 00:50:34 +01:00
parent 86786c9d05
commit d226c32b9b
4 changed files with 113 additions and 35 deletions

View File

@@ -33,6 +33,7 @@ type model struct {
state *bankState
screen screen
quitting bool
height int
login loginModel
menu menuModel
@@ -76,8 +77,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.Type == tea.KeyCtrlC {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
return m, nil
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quitting = true
return m, tea.Quit
}
@@ -130,7 +135,7 @@ func (m *model) View() string {
content = m.admin.View()
}
return screenFrame(m.bankName, m.terminalID, m.region, content)
return screenFrame(m.bankName, m.terminalID, m.region, content, m.height)
}
// --- Screen update handlers ---
@@ -142,8 +147,8 @@ func (m *model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.login.stage == 2 {
// Login always succeeds — this is a honeypot.
logCmd := logAction(m.sess, fmt.Sprintf("LOGIN acct=%s", m.login.accountNum), "ACCESS GRANTED")
m.goToMenu()
return m, tea.Batch(cmd, logCmd)
clearCmd := m.goToMenu()
return m, tea.Batch(cmd, logCmd, clearCmd)
}
return m, cmd
}
@@ -158,36 +163,36 @@ func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
case "1":
m.screen = screenAccountSummary
m.summary = newAccountSummaryModel(m.state.Accounts)
return m, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY")
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY"))
case "2":
m.screen = screenAccountDetail
m.detail = newAccountDetailModel(m.state.Accounts, m.state.Transactions)
return m, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL")
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL"))
case "3":
m.screen = screenTransfer
m.transfer = newTransferModel()
return m, logAction(m.sess, "MENU 3", "WIRE TRANSFER")
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 3", "WIRE TRANSFER"))
case "4":
m.screen = screenHistory
m.history = newHistoryModel(m.state.Accounts, m.state.Transactions)
return m, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY")
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY"))
case "5":
m.screen = screenMessages
m.messages = newMessagesModel(m.state.Messages)
return m, logAction(m.sess, "MENU 5", "SECURE MESSAGES")
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 5", "SECURE MESSAGES"))
case "6":
m.screen = screenChangePin
m.pinInput = ""
m.pinStage = 0
m.pinMessage = ""
return m, logAction(m.sess, "MENU 6", "CHANGE PIN")
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 6", "CHANGE PIN"))
case "7":
m.quitting = true
return m, tea.Batch(logAction(m.sess, "LOGOUT", "SESSION ENDED"), tea.Quit)
case "99", "admin", "ADMIN":
m.screen = screenAdmin
m.admin = newAdminModel()
return m, logAction(m.sess, "ADMIN ACCESS ATTEMPT", "ADMIN SCREEN SHOWN")
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "ADMIN ACCESS ATTEMPT", "ADMIN SCREEN SHOWN"))
}
// Invalid choice, reset.
m.menu.choice = ""
@@ -197,7 +202,7 @@ func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *model) updateAccountSummary(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyMsg); ok {
m.goToMenu()
return m, m.goToMenu()
}
return m, nil
}
@@ -206,7 +211,7 @@ func (m *model) updateAccountDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.detail, cmd = m.detail.Update(msg)
if m.detail.choice == "back" {
m.goToMenu()
return m, tea.Batch(cmd, m.goToMenu())
}
return m, cmd
}
@@ -218,8 +223,14 @@ func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
// Transfer cancelled.
if m.transfer.confirm == "cancelled" {
m.goToMenu()
return m, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED")
clearCmd := m.goToMenu()
return m, tea.Batch(clearCmd, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED"))
}
// Clear screen when transfer steps change content height significantly
// (e.g. confirm→authcode, fields→confirm, authcode→complete).
if m.transfer.step != prevStep {
cmd = tea.Batch(cmd, tea.ClearScreen)
}
// Transfer completed — log it.
@@ -234,7 +245,7 @@ func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
// Completed screen → any key goes back.
if m.transfer.step == transferStepComplete {
if _, ok := msg.(tea.KeyMsg); ok && prevStep == transferStepComplete {
m.goToMenu()
return m, tea.Batch(cmd, m.goToMenu())
}
}
@@ -245,7 +256,7 @@ func (m *model) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.history, cmd = m.history.Update(msg)
if m.history.choice == "back" {
m.goToMenu()
return m, tea.Batch(cmd, m.goToMenu())
}
return m, cmd
}
@@ -254,8 +265,7 @@ func (m *model) updateMessages(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.messages, cmd = m.messages.Update(msg)
if m.messages.choice == "back" {
m.goToMenu()
return m, cmd
return m, tea.Batch(cmd, m.goToMenu())
}
// Log when viewing a message.
if m.messages.viewing >= 0 {
@@ -274,8 +284,7 @@ func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.pinStage == 3 {
m.goToMenu()
return m, nil
return m, m.goToMenu()
}
switch keyMsg.Type {
@@ -302,8 +311,7 @@ func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pinStage = 1
}
case tea.KeyEscape:
m.goToMenu()
return m, nil
return m, m.goToMenu()
case tea.KeyBackspace:
if len(m.pinInput) > 0 {
m.pinInput = m.pinInput[:len(m.pinInput)-1]
@@ -367,8 +375,7 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
// Check for ESC before delegating.
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEscape && !m.admin.locked {
m.goToMenu()
return m, nil
return m, m.goToMenu()
}
var cmd tea.Cmd
@@ -383,7 +390,7 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
// Log lockout.
if m.admin.locked && !prevLocked {
cmd = tea.Batch(cmd, logAction(m.sess,
cmd = tea.Batch(cmd, tea.ClearScreen, logAction(m.sess,
"ADMIN LOCKOUT",
"TERMINAL LOCKED - INCIDENT LOGGED"))
}
@@ -391,14 +398,14 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
// If locked and any key pressed, go back.
if m.admin.locked {
if _, ok := msg.(tea.KeyMsg); ok && prevLocked {
m.goToMenu()
return m, tea.Batch(cmd, m.goToMenu())
}
}
return m, cmd
}
func (m *model) goToMenu() {
func (m *model) goToMenu() tea.Cmd {
unread := 0
for _, msg := range m.state.Messages {
if msg.Unread {
@@ -407,6 +414,7 @@ func (m *model) goToMenu() {
}
m.screen = screenMenu
m.menu = newMenuModel(m.bankName, unread)
return tea.ClearScreen
}
// logAction returns a tea.Cmd that logs an action to the session store.