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

@@ -7,6 +7,7 @@ import (
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.t-juice.club/torjus/oubliette/internal/shell" "git.t-juice.club/torjus/oubliette/internal/shell"
"git.t-juice.club/torjus/oubliette/internal/storage" "git.t-juice.club/torjus/oubliette/internal/storage"
@@ -520,7 +521,7 @@ func TestConfigString(t *testing.T) {
} }
func TestScreenFrame(t *testing.T) { func TestScreenFrame(t *testing.T) {
frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here") frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here", 0)
if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") { if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") {
t.Error("frame should contain bank name in header") t.Error("frame should contain bank name in header")
} }
@@ -531,3 +532,29 @@ func TestScreenFrame(t *testing.T) {
t.Error("frame should contain the content") t.Error("frame should contain the content")
} }
} }
func TestScreenFramePadsLines(t *testing.T) {
frame := screenFrame("TESTBANK", "TB-0001", "NE", "short\n", 0)
for i, line := range strings.Split(frame, "\n") {
w := lipgloss.Width(line)
if w > 0 && w < termWidth {
t.Errorf("line %d has visual width %d, want at least %d: %q", i, w, termWidth, line)
}
}
}
func TestScreenFramePadsToHeight(t *testing.T) {
short := screenFrame("TESTBANK", "TB-0001", "NE", "line1\nline2\n", 30)
lines := strings.Count(short, "\n")
// Total newlines should be at least height-1 (since the last line has no trailing newline).
if lines < 29 {
t.Errorf("padded frame has %d newlines, want at least 29 for height=30", lines)
}
// Without height, no padding.
noPad := screenFrame("TESTBANK", "TB-0001", "NE", "line1\nline2\n", 0)
noPadLines := strings.Count(noPad, "\n")
if noPadLines >= 29 {
t.Errorf("unpadded frame has %d newlines, should be much less than 29", noPadLines)
}
}

View File

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

View File

@@ -165,8 +165,12 @@ func (m transferModel) View() string {
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" TRANSFER QUEUED FOR PROCESSING")) b.WriteString(titleStyle.Render(" TRANSFER QUEUED FOR PROCESSING"))
b.WriteString("\n\n") b.WriteString("\n\n")
routing := m.transfer.RoutingNumber
if len(routing) > 4 {
routing = routing[:4]
}
b.WriteString(baseStyle.Render(fmt.Sprintf(" CONFIRMATION #: WR-%s-%s", b.WriteString(baseStyle.Render(fmt.Sprintf(" CONFIRMATION #: WR-%s-%s",
m.transfer.RoutingNumber[:4], "847291"))) routing, "847291")))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(baseStyle.Render(" STATUS: PENDING FEDWIRE SETTLEMENT")) b.WriteString(baseStyle.Render(" STATUS: PENDING FEDWIRE SETTLEMENT"))
b.WriteString("\n") b.WriteString("\n")

View File

@@ -100,11 +100,33 @@ func formatCurrency(cents int64) string {
return fmt.Sprintf("$%s.%02d", ds, remainder) return fmt.Sprintf("$%s.%02d", ds, remainder)
} }
// padLine pads a single line (which may contain ANSI codes) to termWidth
// using its visual width.
func padLine(line string) string {
w := lipgloss.Width(line)
if w >= termWidth {
return line
}
return line + strings.Repeat(" ", termWidth-w)
}
// padLines pads every line in a multi-line string to termWidth so that
// shorter lines fully overwrite previous content in the terminal.
func padLines(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = padLine(line)
}
return strings.Join(lines, "\n")
}
// screenFrame wraps content in the persistent header and footer. // screenFrame wraps content in the persistent header and footer.
func screenFrame(bankName, terminalID, region, content string) string { // The height parameter is used to pad the output to fill the terminal,
// preventing leftover lines from previous renders bleeding through.
func screenFrame(bankName, terminalID, region, content string, height int) string {
var b strings.Builder var b strings.Builder
// Header. // Header (4 lines).
b.WriteString(divider()) b.WriteString(divider())
b.WriteString("\n") b.WriteString("\n")
b.WriteString(centerText(bankName + " FEDERAL RESERVE SYSTEM")) b.WriteString(centerText(bankName + " FEDERAL RESERVE SYSTEM"))
@@ -117,12 +139,29 @@ func screenFrame(bankName, terminalID, region, content string) string {
// Content. // Content.
b.WriteString(content) b.WriteString(content)
// Footer. // Pad with blank lines between content and footer so the footer
// stays at the bottom and the total output fills the terminal height.
if height > 0 {
const headerLines = 4
const footerLines = 2
// strings.Count gives newlines; add 1 for the line after the last \n.
contentLines := strings.Count(content, "\n") + 1
used := headerLines + contentLines + footerLines
blankLine := strings.Repeat(" ", termWidth)
for i := used; i < height; i++ {
b.WriteString(blankLine)
b.WriteString("\n")
}
}
// Footer (2 lines).
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider()) b.WriteString(divider())
b.WriteString("\n") b.WriteString("\n")
footer := fmt.Sprintf(" TERMINAL: %s | REGION: %s | ENCRYPTED SESSION ACTIVE", terminalID, region) footer := fmt.Sprintf(" TERMINAL: %s | REGION: %s | ENCRYPTED SESSION ACTIVE", terminalID, region)
b.WriteString(dimStyle.Render(padRight(footer, termWidth))) b.WriteString(dimStyle.Render(padRight(footer, termWidth)))
return b.String() // Pad every line to full terminal width so shorter lines overwrite
// leftover content from previous renders.
return padLines(b.String())
} }