Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions internal/tui/core/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,14 @@ type appRuntimeState struct {
endCol int
}

footerErrorLast string
footerErrorText string
footerErrorUntil time.Time
footerErrorLast string
footerErrorText string
footerErrorUntil time.Time
startupVisible bool
startupTick int
startupTypingIndex int
startupCursorOn bool
startupPulsePhase float64
}

type pendingImageAttachment struct {
Expand Down
40 changes: 23 additions & 17 deletions internal/tui/core/app/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,24 @@ package tui
import "github.com/charmbracelet/bubbles/key"

type keyMap struct {
Send key.Binding
Newline key.Binding
CancelAgent key.Binding
NewSession key.Binding
NextPanel key.Binding
PrevPanel key.Binding
FocusInput key.Binding
ToggleHelp key.Binding
Quit key.Binding
ScrollUp key.Binding
ScrollDown key.Binding
PageUp key.Binding
PageDown key.Binding
Top key.Binding
Bottom key.Binding
PasteImage key.Binding
LogViewer key.Binding
Send key.Binding
Newline key.Binding
CancelAgent key.Binding
NewSession key.Binding
OpenWorkspace key.Binding
NextPanel key.Binding
PrevPanel key.Binding
FocusInput key.Binding
ToggleHelp key.Binding
Quit key.Binding
ScrollUp key.Binding
ScrollDown key.Binding
PageUp key.Binding
PageDown key.Binding
Top key.Binding
Bottom key.Binding
PasteImage key.Binding
LogViewer key.Binding
}

func newKeyMap() keyMap {
Expand All @@ -40,6 +41,10 @@ func newKeyMap() keyMap {
key.WithKeys("ctrl+n"),
key.WithHelp("Ctrl+N", "New session"),
),
OpenWorkspace: key.NewBinding(
key.WithKeys("ctrl+o"),
key.WithHelp("Ctrl+O", "Workspace"),
),
NextPanel: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("Tab", "Next panel"),
Expand Down Expand Up @@ -102,6 +107,7 @@ func (k keyMap) ShortHelp() []key.Binding {
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Send, k.Newline, k.CancelAgent, k.NewSession},
{k.OpenWorkspace},
{k.FocusInput, k.NextPanel, k.PrevPanel},
{k.ToggleHelp, k.Quit, k.PasteImage, k.ScrollUp},
{k.PageUp, k.PageDown, k.Top, k.Bottom},
Expand Down
13 changes: 13 additions & 0 deletions internal/tui/core/app/keymap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,16 @@ func TestFullHelpIncludesLogViewer(t *testing.T) {
t.Fatalf("expected full help to include log viewer binding")
}
}

func TestFullHelpIncludesOpenWorkspace(t *testing.T) {
keys := newKeyMap()
help := keys.FullHelp()
for _, row := range help {
for _, binding := range row {
if binding.Help().Key == keys.OpenWorkspace.Help().Key {
return
}
}
}
t.Fatalf("expected full help to include open workspace binding")
}
72 changes: 71 additions & 1 deletion internal/tui/core/app/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)

const (
Expand Down Expand Up @@ -36,6 +37,16 @@ const (

borderDark = "#3d3654"
borderLight = "#4a4268"

startupBackgroundColor = "#000000"
startupLogoBaseColor = "#f3f4f6"
startupMetaColor = "#6b7280"
startupHeaderColor = "#4b5563"
startupMenuActionColor = "#9ca3af"
startupKeyCapBGColor = "#1a1a1a"
startupDividerColor = "#1f2937"
startupPromptColor = "#bd93f9"
startupFooterColor = "#4b5563"
)

type styles struct {
Expand Down Expand Up @@ -83,9 +94,28 @@ type styles struct {
badgeWarning lipgloss.Style
badgeError lipgloss.Style
badgeMuted lipgloss.Style
startupRoot lipgloss.Style
startupHeader lipgloss.Style
startupBrand lipgloss.Style
startupHeaderMeta lipgloss.Style
startupSeparator lipgloss.Style
startupLogo lipgloss.Style
startupSubtitle lipgloss.Style
startupMenu lipgloss.Style
startupMenuItem lipgloss.Style
startupKeyCap lipgloss.Style
startupMenuAction lipgloss.Style
startupInput lipgloss.Style
startupDivider lipgloss.Style
startupPrompt lipgloss.Style
startupTyping lipgloss.Style
startupCursor lipgloss.Style
startupFooter lipgloss.Style
}

func newStyles() styles {
lipgloss.SetColorProfile(termenv.TrueColor)

headerAccent := lipgloss.AdaptiveColor{Light: coralAccent, Dark: purpleLight}

panel := lipgloss.NewStyle().
Expand All @@ -96,7 +126,7 @@ func newStyles() styles {
return styles{
doc: lipgloss.NewStyle().
Padding(1, 2, 0, 2).
UnsetBackground(),
Background(lipgloss.Color(startupBackgroundColor)),
headerBar: lipgloss.NewStyle().
UnsetBackground(),
headerBrand: lipgloss.NewStyle().
Expand Down Expand Up @@ -206,6 +236,46 @@ func newStyles() styles {
badgeWarning: badge("", warningYellow),
badgeError: badge("", errorRed),
badgeMuted: badge("", stoneGray),
startupRoot: lipgloss.NewStyle().
UnsetBackground(),
startupHeader: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupHeaderColor)),
startupBrand: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupHeaderColor)).
Bold(true),
startupHeaderMeta: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupHeaderColor)),
startupSeparator: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupHeaderColor)),
startupLogo: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupLogoBaseColor)).
Bold(true),
startupSubtitle: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupMetaColor)).
Align(lipgloss.Center),
startupMenu: lipgloss.NewStyle(),
startupMenuItem: lipgloss.NewStyle(),
startupKeyCap: lipgloss.NewStyle().
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color(startupKeyCapBGColor)).
Padding(0, 1),
startupMenuAction: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupMenuActionColor)),
startupInput: lipgloss.NewStyle(),
startupDivider: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupDividerColor)),
startupPrompt: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupPromptColor)).
Bold(true),
startupTyping: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupMetaColor)),
startupCursor: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#ffffff")).
Reverse(true),
startupFooter: lipgloss.NewStyle().
Foreground(lipgloss.Color(startupFooterColor)).
Align(lipgloss.Center),
}
}

Expand Down
106 changes: 106 additions & 0 deletions internal/tui/core/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"path/filepath"
"strings"
"time"
Expand Down Expand Up @@ -51,6 +52,11 @@ const sessionSwitchBusyMessage = "cannot switch sessions while run or compact is
const logViewerEntryLimit = 500
const logViewerPersistDebounce = 300 * time.Millisecond
const footerErrorFlashDuration = 8 * time.Second
const startupAnimationTickInterval = 180 * time.Millisecond
const startupTypingStartDelayTicks = 6
const startupTypingStepTicks = 1
const startupCursorBlinkStepTicks = 3
const startupPulseStepTicks = 1

type sessionLogPersistenceRuntime interface {
LoadSessionLogEntries(ctx context.Context, sessionID string) ([]tuiservices.SessionLogEntry, error)
Expand Down Expand Up @@ -294,6 +300,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
}
case tea.KeyMsg:
if a.startupVisible {
if model, cmd, handled := a.handleStartupKey(typed, cmds); handled {
return model, cmd
}
}
if key.Matches(typed, a.keys.Quit) {
return a, tea.Quit
}
Expand Down Expand Up @@ -353,6 +364,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.startDraftSession()
return a, tea.Batch(cmds...)
}
if key.Matches(typed, a.keys.OpenWorkspace) {
a.openFileBrowser()
return a, tea.Batch(cmds...)
}

if key.Matches(typed, a.keys.PasteImage) {
if err := a.addImageFromClipboard(); err != nil {
Expand Down Expand Up @@ -671,6 +686,96 @@ func (a App) now() time.Time {
return a.nowFn()
}

// startupAnimationTickCmd 发送启动页动画节拍消息,用于驱动呼吸灯与打字机效果。
func startupAnimationTickCmd() tea.Cmd {
return tea.Tick(startupAnimationTickInterval, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}

// advanceStartupAnimation 推进启动页动效状态,包括呼吸 phase、打字索引与光标闪烁。
func (a *App) advanceStartupAnimation() {
if !a.startupVisible {
return
}
a.startupTick++

if startupPulseStepTicks > 0 && a.startupTick%startupPulseStepTicks == 0 {
if startupBreathCycleTicks > 0 {
step := 2 * math.Pi / float64(startupBreathCycleTicks)
a.startupPulsePhase += step
if a.startupPulsePhase >= 2*math.Pi {
a.startupPulsePhase -= 2 * math.Pi
}
}
}
if startupCursorBlinkStepTicks > 0 && a.startupTick%startupCursorBlinkStepTicks == 0 {
a.startupCursorOn = !a.startupCursorOn
}
if a.startupTick < startupTypingStartDelayTicks {
return
}
if startupTypingStepTicks > 0 && a.startupTick%startupTypingStepTicks != 0 {
return
}
maxChars := len([]rune(startupTypingPlaceholder))
if a.startupTypingIndex < maxChars {
a.startupTypingIndex++
}
}

// dismissStartup 隐藏启动页并恢复输入焦点,确保后续按键进入主流程处理。
func (a *App) dismissStartup() {
if !a.startupVisible {
return
}
a.startupVisible = false
a.focus = panelInput
a.applyFocus()
a.applyComponentLayout(false)
}

// handleStartupKey 处理启动页专属按键网关,必要时切换到主线输入流程。
func (a App) handleStartupKey(typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd, bool) {
switch {
case key.Matches(typed, a.keys.NewSession):
a.dismissStartup()
a.startDraftSession()
return a, tea.Batch(cmds...), true
case key.Matches(typed, a.keys.OpenWorkspace):
a.dismissStartup()
a.openFileBrowser()
return a, tea.Batch(cmds...), true
case typed.Type == tea.KeyRunes && len(typed.Runes) == 1 && typed.Runes[0] == '/':
a.dismissStartup()
a.input.SetValue("/")
a.state.InputText = a.input.Value()
a.refreshCommandMenu()
a.applyComponentLayout(false)
return a, tea.Batch(cmds...), true
case key.Matches(typed, a.keys.FocusInput):
return a, tea.Quit, true
case key.Matches(typed, a.keys.Quit):
return a, tea.Quit, true
case isStartupRegularInput(typed):
a.dismissStartup()
model, cmd := a.updateInputPanel(typed, typed, cmds)
return model, cmd, true
default:
return a, tea.Batch(cmds...), true
}
}

// isStartupRegularInput 判断按键是否属于可直接落入输入框的常规字符输入。
func isStartupRegularInput(msg tea.KeyMsg) bool {
switch msg.Type {
case tea.KeyRunes, tea.KeySpace:
return true
default:
return false
}
}

type logPersistFlushMsg struct {
Version int
}
Expand Down Expand Up @@ -2671,6 +2776,7 @@ func (a App) currentStatusSnapshot() tuistatus.Snapshot {
}

func (a *App) startDraftSession() {
a.dismissStartup()
a.setActiveSessionID("")
a.startupScreenLocked = true
a.startupIntroActive = false
Expand Down
Loading
Loading