diff --git a/tui/graph.go b/tui/graph.go index c6e9996..db52b84 100644 --- a/tui/graph.go +++ b/tui/graph.go @@ -144,7 +144,7 @@ func (m model) graphMini(width, height int) string { func (m model) graphCompact() string { status := firstNonEmpty(m.graph.status, "missing") - return m.graphMini(9, 3) + " " + graphStatusStyle(status).Render(strings.ToUpper(status)) + + return graphStatusStyle(status).Render("◇ "+strings.ToUpper(status)) + mutedStyle.Render(fmt.Sprintf(" %d nodes · %d edges", m.graph.nodes, m.graph.edges)) } @@ -152,7 +152,7 @@ func (m model) graphExplorer() string { width, height := maxInt(1, m.width-4), maxInt(1, m.height-2) status := firstNonEmpty(m.graph.status, "missing") lines := []string{ - brandStyle.Render("PROJECT GRAPH") + " " + graphStatusStyle(status).Render(strings.ToUpper(status)), + brandStyle.Render("◆ PROJECT GRAPH") + " " + graphStatusStyle(status).Render(strings.ToUpper(status)), mutedStyle.Render(fmt.Sprintf("%d nodes · %d edges · %d communities", m.graph.nodes, m.graph.edges, m.graph.communities)), "", m.graphMini(minInt(48, maxInt(22, width/2)), minInt(14, maxInt(8, height/3))), @@ -164,12 +164,14 @@ func (m model) graphExplorer() string { nodes := m.visibleGraphNodes() for index, node := range nodes { marker, style := "·", softStyle + rowStyle := lipgloss.NewStyle() if index == minInt(m.graphSelected, maxInt(0, len(nodes)-1)) { marker, style = "›", titleStyle + rowStyle = rowStyle.Background(lipgloss.Color(surfaceHi)).Padding(0, 1) } color := communityColors[node.community%len(communityColors)] row := lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render("●") + " " + style.Render(compactText(node.label, maxInt(8, width-20))) - lines = append(lines, marker+" "+row+mutedStyle.Render(fmt.Sprintf(" degree %d", node.degree))) + lines = append(lines, rowStyle.Render(marker+" "+row+mutedStyle.Render(fmt.Sprintf(" degree %d", node.degree)))) } if len(nodes) == 0 { lines = append(lines, mutedStyle.Render(firstNonEmpty(m.graph.message, "No indexed nodes yet."))) @@ -200,9 +202,15 @@ func (m model) graphExplorer() string { } } } - help := "↑↓/hjkl select · +/- zoom · / search · Tab communities · Enter neighbors · p path · r refresh · Esc close" - lines = append(lines, "", mutedStyle.Render(help)) - return lipgloss.NewStyle().Width(width).MaxWidth(width).Height(height).MaxHeight(height).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color(cyan)).Padding(0, 1).Render(strings.Join(lines, "\n")) + help := "↑↓ select · Enter open · Esc close" + if width >= 60 { + help = "↑↓ select · / search · Enter neighbors · r refresh · Esc close" + } + if width >= 100 { + help = "↑↓/hjkl select · +/- zoom · / search · Tab communities · Enter neighbors · p path · r refresh · Esc close" + } + lines = append(lines, "", contextStyle.Copy().Width(maxInt(1, width-2)).Render(mutedStyle.Render(help))) + return lipgloss.NewStyle().Background(lipgloss.Color(deep)).Width(width).MaxWidth(width).Height(height).MaxHeight(height).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(cyan)).Padding(0, 1).Render(strings.Join(lines, "\n")) } func (m model) visibleGraphNodes() []graphNode { diff --git a/tui/main.go b/tui/main.go index 6e07310..df6f8a8 100644 --- a/tui/main.go +++ b/tui/main.go @@ -1247,7 +1247,11 @@ func boolValue(values map[string]any, key string) bool { func (m *model) resize() { width := m.contentWidth() mainWidth, _ := m.layoutWidths() - m.input.SetWidth(width) + composerWidth := width + if m.height >= 12 { + composerWidth = maxInt(1, width-4) + } + m.input.SetWidth(composerWidth) m.input.SetHeight(m.composerHeight()) m.apiInput.SetWidth(maxInt(1, minInt(68, m.width-14))) m.view.SetWidth(mainWidth) @@ -1262,11 +1266,11 @@ func (m model) contentWidth() int { func (m model) layoutWidths() (int, int) { available := m.contentWidth() - if m.width < 110 { + if m.width < 120 || m.height < 24 { return available, 0 } railWidth := minInt(30, maxInt(24, available/4)) - return maxInt(1, available-railWidth-2), railWidth + return maxInt(1, available-railWidth-1), railWidth } func (m model) composerHeight() int { @@ -1317,9 +1321,6 @@ func (m model) usageSummary() string { func (m model) chatChromeHeight() int { width := m.contentWidth() nonViewport := []string{m.chatHeader()} - if !m.compactChat() { - nonViewport = append(nonViewport, rule(width)) - } if progress := m.progressBlock(width); progress != "" { nonViewport = append(nonViewport, progress) } @@ -1373,7 +1374,20 @@ func (m *model) syncViewport() { func (m *model) history(width int) string { if len(m.messages) == 0 { - return softStyle.Render("No messages yet. Start with a question, or type / for commands.") + if width < 28 { + return softStyle.Render(compactText("Ready — ask anything", width)) + } + body := strings.Join([]string{ + brandStyle.Render("◆ WORKSPACE READY"), + titleStyle.Render("What will we build?"), + mutedStyle.Render("Ask naturally, or start with a focused workflow."), + "", + softStyle.Render("/plan") + mutedStyle.Render(" plan") + " " + + softStyle.Render("/attach") + mutedStyle.Render(" context") + " " + + softStyle.Render("/graph") + mutedStyle.Render(" inspect"), + }, "\n") + boxWidth := maxInt(1, minInt(68, width-8)) + return welcomeStyle.Copy().Width(boxWidth).MaxWidth(boxWidth).Render(body) } var lines []string for index := range m.messages { @@ -1394,8 +1408,9 @@ func (m *model) history(width int) string { body = message.toolDetail } } + innerWidth := maxInt(1, width-5) if message.role == "assistant" && body != "" && !message.streaming { - renderWidth := maxInt(1, width-2) + renderWidth := innerWidth if message.renderedWidth != renderWidth { message.rendered = renderMarkdown(body, renderWidth) message.renderedWidth = renderWidth @@ -1411,10 +1426,27 @@ func (m *model) history(width int) string { } body += cursor } - block := labelStyle.Render(label) + "\n" + body - lines = append(lines, lipgloss.NewStyle().Width(maxInt(1, width-2)).Render(block)) + if width < 12 { + lines = append(lines, softStyle.Render(compactText(label+" "+body, width))) + continue + } + block := labelStyle.Render(label) + if body != "" { + block += "\n" + body + } + boxWidth := maxInt(1, width-5) + style := assistantStyle + switch message.role { + case "user": + style = userStyle + case "thinking": + style = thinkingStyle + case "receipt": + style = receiptBoxStyle + } + lines = append(lines, style.Copy().Width(boxWidth).MaxWidth(boxWidth).Render(block)) } - return strings.Join(lines, "\n") + return strings.Join(lines, "\n\n") } func (m model) View() tea.View { @@ -1489,16 +1521,13 @@ func (m model) chatView() string { mainWidth, panelWidth := m.layoutWidths() header := m.chatHeader() historyHeight := m.historyHeight() - main := quietStyle.Copy().Width(mainWidth).MaxWidth(mainWidth).Height(historyHeight).MaxHeight(historyHeight).Render( + main := lipgloss.NewStyle().Width(mainWidth).MaxWidth(mainWidth).Height(historyHeight).MaxHeight(historyHeight).Render( limitRows(m.view.View(), historyHeight), ) if panelWidth > 0 { - main = lipgloss.JoinHorizontal(lipgloss.Top, main, " ", m.activityRail()) + main = lipgloss.JoinHorizontal(lipgloss.Top, main, " ", m.activityRail()) } blocks := []string{header} - if !m.compactChat() { - blocks = append(blocks, rule(contentWidth)) - } blocks = append(blocks, main) if progress := m.progressBlock(contentWidth); progress != "" { blocks = append(blocks, progress) @@ -1513,52 +1542,88 @@ func (m model) chatView() string { func (m model) chatHeader() string { width := m.contentWidth() - compact := m.width < 80 || m.height < 16 - modelLabel := firstNonEmpty(m.provider, "provider pending") + " · " + firstNonEmpty(m.modelName, "model pending") + if width < 4 { + return brandStyle.Render(compactText("K", width)) + } + innerWidth := maxInt(1, width-2) + brand := "◆ OPENKYROZEN" + if innerWidth < 24 { + brand = "◆ KYROZEN" + } + left := brandStyle.Render(compactText(brand, innerWidth)) mode := strings.ToUpper(firstNonEmpty(m.effectiveMode, m.interactionMode, "ask")) - if m.height > 0 && m.height < 12 { - return lipgloss.NewStyle().Width(width).MaxWidth(width).Render( - brandStyle.Render("OPENKYROZEN") + " " + mutedStyle.Render(mode), - ) + + statusText := compactText(firstNonEmpty(m.status, "Ready"), maxInt(4, minInt(18, innerWidth/3))) + status := mutedStyle.Render("● " + statusText) + if m.busy { + status = amberStyle.Render(taskSpinner(m.motionFrame) + " " + statusText) + } else if strings.EqualFold(statusText, "ready") { + status = greenStyle.Render("● READY") } - lines := []string{ - brandStyle.Render("OPENKYROZEN") + " " + mutedStyle.Render(mode), - softStyle.Render("MODEL ") + softStyle.Render(compactText(modelLabel, width-8)), - softStyle.Render("USAGE ") + softStyle.Render(compactText(m.usageSummary(), width-8)), + right := "" + leftBudget := innerWidth + if innerWidth >= 38 { + right = status + leftBudget = maxInt(1, innerWidth-lipgloss.Width(right)-2) } - if !compact && m.workspace != "" { - lines = append(lines, mutedStyle.Render("WORKSPACE "+compactText(m.workspace, width-11))) + add := func(item string) { + candidate := left + mutedStyle.Render(" · ") + item + if lipgloss.Width(candidate) <= leftBudget { + left = candidate + } } + add(badgeStyle.Render(mode)) _, panelWidth := m.layoutWidths() - if !compact && panelWidth == 0 && m.height >= 18 { - lines = append(lines, m.graphCompact()) + if panelWidth == 0 { + graphStatus := strings.ToUpper(firstNonEmpty(m.graph.status, "missing")) + add(graphStatusStyle(graphStatus).Render("◇ " + graphStatus)) + } + if m.modelName != "" { + add(softStyle.Render(compactText(m.modelName, 22))) + } + tokens := m.usagePromptTokens + m.usageCompletionTokens + m.usageReasoningTokens + usage := formatUsageTokens(tokens) + " TOK" + if m.usageCostPicos > 0 { + usage += " · " + formatUsageCost(m.usageCostPicos) } - return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(strings.Join(lines, "\n")) + add(mutedStyle.Render(usage)) + + row := left + if right != "" { + gap := maxInt(2, innerWidth-lipgloss.Width(left)-lipgloss.Width(right)) + row += strings.Repeat(" ", gap) + right + } + return contextStyle.Copy().Width(innerWidth).MaxWidth(innerWidth).Render(row) } func (m model) chatFooter(width int) string { - if width < 80 || m.height < 18 { - status := compactText(firstNonEmpty(m.status, "Ready"), maxInt(4, width/4)) - return mutedStyle.Copy().Width(width).MaxWidth(width).Render( - compactText("○ "+status+" · ↵ send · / commands · ^C quit", width), - ) + if width < 4 { + return "" } - hints := "Enter send · ↑↓ commands · PgUp/PgDn scroll · Ctrl+C quit" - if width >= 80 && m.height >= 18 { - hints = "Enter send · Shift+Enter newline · ↑↓ commands · PgUp/PgDn scroll · Ctrl+C quit" + hints := "↵ send · / commands · ^C quit" + if width >= 72 && m.height >= 18 { + hints = "Enter send · Shift+Enter newline · / commands · PgUp/PgDn scroll · Ctrl+C quit" } - status := mutedStyle.Render("○ " + firstNonEmpty(m.status, "Ready")) - if m.busy { - status = amberStyle.Render(taskSpinner(m.motionFrame) + " " + firstNonEmpty(m.status, "Working…")) + workspace := "" + if m.workspace != "" && width >= 52 { + workspace = compactText(filepath.Base(m.workspace), maxInt(8, width/4)) + } + if workspace == "" { + return mutedStyle.Copy().Width(width).MaxWidth(width).Render(compactText(hints, width)) } - return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(status + " " + mutedStyle.Render(hints)) + right := compactText(hints, maxInt(1, width-lipgloss.Width(workspace)-2)) + gap := maxInt(2, width-lipgloss.Width(workspace)-lipgloss.Width(right)) + return mutedStyle.Copy().Width(width).MaxWidth(width).Render(workspace + strings.Repeat(" ", gap) + right) } func (m model) paletteView(width int) string { if len(m.palette) == 0 { return "" } - rowWidth := maxInt(1, width-2) + if width < 12 { + return titleStyle.Render(compactText("COMMANDS", width)) + } + rowWidth := maxInt(1, width-6) visible := maxInt(1, minInt(len(m.palette), 5)) start := 0 if m.paletteIndex >= visible { @@ -1569,7 +1634,7 @@ func (m model) paletteView(width int) string { if len(m.palette) > visible { title += " · ↑↓ MORE" } - lines := []string{titleStyle.Render(title)} + lines := []string{sectionStyle.Render(title)} for index := start; index < end; index++ { item := m.palette[index] cursor := mutedStyle.Render("·") @@ -1581,19 +1646,22 @@ func (m model) paletteView(width int) string { cursor = brandStyle.Render("»") } } - row := cursor + " " + softStyle.Render("/"+item.name) + " " + mutedStyle.Render(item.description) + descriptionWidth := maxInt(1, rowWidth-len(item.name)-5) + row := cursor + " " + titleStyle.Render("/"+item.name) + " " + mutedStyle.Render(compactText(item.description, descriptionWidth)) lines = append(lines, rowStyle.MaxWidth(rowWidth).Render(row)) } - return strings.Join(lines, "\n") + panelWidth := maxInt(1, width-4) + return paletteStyle.Copy().Width(panelWidth).MaxWidth(panelWidth).Render(strings.Join(lines, "\n")) } func (m model) composer(width int) string { if m.height > 0 && m.height < 12 { return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(m.input.View()) } - style := quietStyle.Copy().Width(width).MaxWidth(width).BorderTop(true).BorderBottom(true).BorderForeground(lipgloss.Color(border)) + panelWidth := maxInt(1, width-4) + style := composerStyle.Copy().Width(panelWidth).MaxWidth(panelWidth) if m.input.Focused() { - style = style.BorderBottomForeground(lipgloss.Color(cyan)) + style = style.BorderForeground(lipgloss.Color(cyan)) } return style.Render(m.input.View()) } @@ -1603,59 +1671,65 @@ func (m model) progressBlock(width int) string { return "" } message := firstNonEmpty(m.thinkingText, m.status, "Working…") - return lipgloss.NewStyle().Width(width).Render(amberStyle.Render(taskSpinner(m.motionFrame)) + " " + softStyle.Render(message)) + innerWidth := maxInt(1, width-2) + line := amberStyle.Render(taskSpinner(m.motionFrame)) + " " + softStyle.Render(compactText(message, maxInt(1, innerWidth-3))) + return contextStyle.Copy().Width(innerWidth).MaxWidth(innerWidth).Render(line) } func (m model) activityRail() string { _, panelWidth := m.layoutWidths() width := maxInt(1, panelWidth-2) + textWidth := maxInt(1, width-2) height := m.historyHeight() - compact := height < 22 graphStatus := firstNonEmpty(m.graph.status, "missing") - lines := []string{titleStyle.Render("ACTIVITY")} - if !compact { - lines = []string{ - titleStyle.Render("PROJECT GRAPH") + " " + graphStatusStyle(graphStatus).Render(strings.ToUpper(graphStatus)), - m.graphMini(minInt(22, width), 8), - mutedStyle.Render(fmt.Sprintf("%d nodes · %d edges", m.graph.nodes, m.graph.edges)), - "", titleStyle.Render("ACTIVITY"), rule(width), mutedStyle.Render("PROVIDER"), softStyle.Render(firstNonEmpty(m.provider, "pending")), - } - } else { - lines = append(lines, rule(width), mutedStyle.Render("PROVIDER"), softStyle.Render(firstNonEmpty(m.provider, "pending"))) - } - if m.modelName != "" { - lines = append(lines, mutedStyle.Render(compactText(m.modelName, width))) + mapHeight := 3 + if height >= 22 { + mapHeight = 5 } - if m.workspace != "" && !compact { - lines = append(lines, "", mutedStyle.Render("WORKSPACE"), softStyle.Render(compactText(m.workspace, width))) - } - lines = append(lines, "", mutedStyle.Render("USAGE"), softStyle.Render(m.usageSummary())) - if !compact { - lines = append(lines, "", mutedStyle.Render("MODE"), softStyle.Render( - "preference "+firstNonEmpty(m.interactionMode, "auto")+" · active "+firstNonEmpty(m.effectiveMode, "ask"), - )) - lines = append(lines, "", titleStyle.Render(fmt.Sprintf("TASKS %d", len(m.tasks)))) - } else { - lines = append(lines, "", titleStyle.Render(fmt.Sprintf("TASKS %d", len(m.tasks)))) + lines := []string{ + sectionStyle.Render("PROJECT GRAPH") + " " + graphStatusStyle(graphStatus).Render(strings.ToUpper(graphStatus)), + m.graphMini(minInt(22, textWidth), mapHeight), + mutedStyle.Render(fmt.Sprintf("%d nodes · %d edges", m.graph.nodes, m.graph.edges)), + "", + sectionStyle.Render(fmt.Sprintf("TASKS %d", len(m.tasks))), } if len(m.tasks) == 0 { lines = append(lines, mutedStyle.Render("No active tasks")) } - for _, task := range m.tasks { + maxTasks := maxInt(1, height-mapHeight-12) + for index, task := range m.tasks { + if index >= maxTasks { + lines = append(lines, mutedStyle.Render(fmt.Sprintf("+%d more", len(m.tasks)-index))) + break + } icon, stateStyle, label := taskState(task.status, m.motionFrame) if task.id == m.taskFlashID && m.taskFlashTick > 0 && task.status == "succeeded" { label = "completed ·" } - if compact { - lines = append(lines, stateStyle.Render(icon)+" "+softStyle.Render(compactText(task.description, width-12))+" "+stateStyle.Render(label)) - } else { - lines = append(lines, stateStyle.Render(icon)+" "+softStyle.Render(compactText(task.description, width-4)), stateStyle.Render(label)) - } + row := stateStyle.Render(icon) + " " + softStyle.Render(compactText(task.description, maxInt(4, textWidth-lipgloss.Width(label)-4))) + " " + stateStyle.Render(label) + lines = append(lines, row) + } + workspace := "global workspace" + if m.workspace != "" { + workspace = filepath.Base(m.workspace) + } + usage := "No usage yet" + if m.usageAttempts > 0 { + usage = fmt.Sprintf("%d calls · %s", m.usageAttempts, firstNonEmpty(m.usageScope, "workspace")) + } + lines = append(lines, "", sectionStyle.Render("SESSION"), softStyle.Render(compactText(workspace, textWidth)), mutedStyle.Render(compactText(usage, textWidth))) + preferredMode := firstNonEmpty(m.interactionMode, "auto") + activeMode := firstNonEmpty(m.effectiveMode, "ask") + if m.interactionMode != "" && m.effectiveMode != "" && preferredMode != activeMode { + lines = append(lines, + mutedStyle.Render(compactText("preference "+preferredMode, textWidth)), + mutedStyle.Render(compactText("active "+activeMode, textWidth)), + ) } // Keep the rail inside the transcript row budget. Without this cap, // JoinHorizontal adopts a task-heavy rail's natural height and pushes the // composer/footer below the terminal viewport. - return lipgloss.NewStyle().Width(maxInt(1, panelWidth)).MaxWidth(maxInt(1, panelWidth)).Height(height).MaxHeight(height).BorderLeft(true).BorderForeground(lipgloss.Color(border)).PaddingLeft(2).Render(limitRows(strings.Join(lines, "\n"), height)) + return activityStyle.Copy().Width(width).MaxWidth(width).Height(height).MaxHeight(height).Render(limitRows(strings.Join(lines, "\n"), height)) } func (m model) taskPanel() string { return m.activityRail() } diff --git a/tui/main_test.go b/tui/main_test.go index bd4cd2f..a13730c 100644 --- a/tui/main_test.go +++ b/tui/main_test.go @@ -148,9 +148,12 @@ func TestUsageEventAppearsInChatAndSettings(t *testing.T) { }) m.resize() chat := m.View().Content - if !strings.Contains(chat, "MODEL") || !strings.Contains(chat, "deepseek-chat") || !strings.Contains(chat, "USAGE") || !strings.Contains(chat, "$0.01") { + if !strings.Contains(chat, "deepseek-chat") || !strings.Contains(chat, "2.1K TOK") || !strings.Contains(chat, "$0.01") { t.Fatalf("chat header omitted model or usage summary: %s", chat) } + if got := lipgloss.Height(m.chatHeader()); got != 1 { + t.Fatalf("context bar rendered %d rows, want 1", got) + } m.screen = screenSettings settings := m.View().Content if !strings.Contains(settings, "SESSION") || !strings.Contains(settings, "MEMORY") || !strings.Contains(settings, "workspace") { @@ -437,7 +440,7 @@ func TestToolReceiptsKeepSemanticStatus(t *testing.T) { func TestTaskStateTransitionAndAdaptiveRail(t *testing.T) { m := initialModel("", false) m.reducedMotion = false - m.width = 140 + m.width, m.height = 140, 30 m.tasks = []taskItem{{id: "t1", description: "Build", status: "running"}} if _, rail := m.layoutWidths(); rail == 0 { t.Fatal("wide layout did not allocate an activity rail") @@ -455,6 +458,82 @@ func TestTaskStateTransitionAndAdaptiveRail(t *testing.T) { } } +func TestFocusedCockpitRailBreakpointAndCompactGraph(t *testing.T) { + m := initialModel("", true) + m.graph = graphSnapshot{status: "ready", nodes: 12, edges: 18} + for _, size := range [][2]int{{119, 40}, {120, 23}} { + m.width, m.height = size[0], size[1] + if _, rail := m.layoutWidths(); rail != 0 { + t.Fatalf("terminal %dx%d unexpectedly rendered a rail", size[0], size[1]) + } + if header := m.chatHeader(); !strings.Contains(header, "READY") { + t.Fatalf("terminal %dx%d lost compact graph state: %s", size[0], size[1], header) + } + } + m.width, m.height = 120, 24 + if _, rail := m.layoutWidths(); rail == 0 { + t.Fatal("120x24 did not enable the contextual rail") + } + if header := m.chatHeader(); strings.Contains(header, "◇ READY") { + t.Fatalf("wide header duplicated graph telemetry: %s", header) + } +} + +func TestFocusedCockpitMessageHierarchyAndEmptyState(t *testing.T) { + m := initialModel("", true) + empty := m.history(80) + if !strings.Contains(empty, "WORKSPACE READY") || !strings.Contains(empty, "/plan") || !strings.Contains(empty, "/graph") { + t.Fatalf("empty state is incomplete: %s", empty) + } + m.messages = []chatMessage{ + {role: "user", text: "Review this"}, + {role: "assistant", text: "Working **carefully**.", streaming: true}, + {role: "thinking", text: "Inspecting the graph"}, + {role: "receipt", toolAction: "read_file", status: "success", toolDetail: "private details"}, + } + history := m.history(80) + for _, label := range []string{"YOU", "KYROZEN", "THINKING", "TOOL · read_file · SUCCESS", "▌"} { + if !strings.Contains(history, label) { + t.Fatalf("message hierarchy omitted %q: %s", label, history) + } + } + if strings.Contains(history, "private details") { + t.Fatalf("collapsed tool receipt exposed details: %s", history) + } +} + +func TestFocusedCockpitDoesNotDuplicateModelTelemetry(t *testing.T) { + m := initialModel("/tmp/workspace", true) + m.width, m.height, m.screen = 140, 40, screenChat + m.modelName = "deepseek-v4-flash" + m.usageScope, m.usageAttempts = "workspace", 4 + m.usagePromptTokens, m.usageCompletionTokens, m.usageReasoningTokens = 5400, 1800, 620 + m.resize() + view := m.View().Content + if got := strings.Count(view, "deepseek-v4-flash"); got != 1 { + t.Fatalf("model telemetry appeared %d times, want once: %s", got, view) + } + if got := strings.Count(view, "7.8K TOK"); got != 1 || !strings.Contains(view, "4 calls · workspace") { + t.Fatalf("session usage hierarchy is incomplete or duplicated: %s", view) + } +} + +func TestFocusedCockpitPaletteAndModalUseUnifiedFrame(t *testing.T) { + m := initialModel("", true) + m.palette = commandMatches("/") + palette := m.paletteView(80) + if !strings.Contains(palette, "╭") || !strings.Contains(palette, "COMMANDS") { + t.Fatalf("command palette lost the cockpit frame: %s", palette) + } + m.width, m.height, m.screen = 80, 24, screenApproval + m.approvalTool = "run_cmd" + m.resize() + modal := m.View().Content + if !strings.Contains(modal, "╭") || !strings.Contains(modal, "APPROVAL REQUIRED") { + t.Fatalf("approval modal lost the cockpit frame: %s", modal) + } +} + func TestRenderedChatFitsSmallWindow(t *testing.T) { m := initialModel("", false) m.width, m.height = 60, 16 diff --git a/tui/screenshots/focused-cockpit-after.svg b/tui/screenshots/focused-cockpit-after.svg new file mode 100644 index 0000000..47ccd4c --- /dev/null +++ b/tui/screenshots/focused-cockpit-after.svg @@ -0,0 +1 @@ +OpenKyrozen TUI — focused cockpit◆ OPENKYROZEN · ASK · deepseek-v4-flash · 7.8K TOK · $0.02● READYYOUPROJECT GRAPHREADYMake the terminal feel focused, calm, and unmistakably Kyrozen.◆ ·· KYROZEN · ●## Focused cockpit●● The transcript now owns the screen while graph, tasks, and memory stay close at hand. 38 nodes · 61 edges • Calm context hierarchy TASKS 3 • Compact, privacy-safe receipts ✓Map terminal…completed • Responsive from tiny terminals to fullscreen ◐Polish focused…running○Verify respons…pendingTOOL · read_file · SUCCESSSESSIONkyrozen-demo4 calls · workspacepreference autoactive ask╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│› Ask Kyrozen anything…││› ││› │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯kyrozen-demo Enter send · Shift+Enter newline · / commands · PgUp/PgDn scroll · Ctrl+C quit \ No newline at end of file diff --git a/tui/screenshots/focused-cockpit-before.svg b/tui/screenshots/focused-cockpit-before.svg new file mode 100644 index 0000000..90875de --- /dev/null +++ b/tui/screenshots/focused-cockpit-before.svg @@ -0,0 +1 @@ +OpenKyrozen TUI — beforeOPENKYROZENASKMODEL deepseek · deepseek-v4-flashUSAGE 7.8K tokens · $0.02 · 4 call(s)WORKSPACE /tmp/kyrozen-demo────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────YOUPROJECT GRAPHREADYMake the terminal feel focused, calm, and unmistakably Kyrozen.◆KYROZEN · · ## Focused cockpit · ●●The transcript now owns the screen while graph, tasks, and memory stay close at hand. • Calm context hierarchy • Compact, privacy-safe receipts ●• Responsive from tiny terminals to fullscreen 38 nodes · 61 edgesTOOL · read_file · SUCCESSACTIVITY────────────────────────────PROVIDERdeepseekdeepseek-v4-flashWORKSPACE/tmp/kyrozen-demoUSAGE7.8K tokens · $0.02 · 4call(s)MODEpreference auto · active askTASKS 3✓Map terminal layoutcompleted◐Polish focused cockpit› Ask Kyrozen anything…› › ○ ReadyEnter send · Shift+Enter newline · ↑↓ commands · PgUp/PgDn scroll · Ctrl+C quit \ No newline at end of file diff --git a/tui/theme.go b/tui/theme.go index 15a7d22..c364275 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -10,18 +10,18 @@ import ( ) const ( - ink = "#000000" - deep = "#080B0E" - surface = "#141A1F" - surfaceHi = "#202A31" - border = "#53616B" - white = "#FFFFFF" - offWhite = "#E7EDF1" - muted = "#AAB6BE" - cyan = "#00F0FF" - green = "#55D187" - amber = "#F0B35A" - red = "#F4778D" + ink = "#060A0D" + deep = "#0B1117" + surface = "#111A22" + surfaceHi = "#192632" + border = "#587180" + white = "#F6FBFD" + offWhite = "#DBE7EC" + muted = "#A9BBC4" + cyan = "#2DE2E6" + green = "#7CE38B" + amber = "#F6C177" + red = "#F38BA8" ) var ( @@ -33,7 +33,19 @@ var ( ruleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(border)) quietStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).Padding(0, 1) focusStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).BorderBottom(true).BorderForeground(lipgloss.Color(cyan)).Padding(0, 1) - modalStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color(border)).Padding(1, 2) + modalStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(border)).Padding(1, 2) + + contextStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)) + badgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cyan)).Background(lipgloss.Color(surfaceHi)).Bold(true).Padding(0, 1) + sectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(muted)).Bold(true) + welcomeStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(border)).Padding(1, 2) + assistantStyle = lipgloss.NewStyle().BorderLeft(true).BorderForeground(lipgloss.Color(border)).PaddingLeft(1) + userStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).BorderLeft(true).BorderForeground(lipgloss.Color(cyan)).Padding(0, 1) + thinkingStyle = lipgloss.NewStyle().BorderLeft(true).BorderForeground(lipgloss.Color(amber)).PaddingLeft(1) + receiptBoxStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).Padding(0, 1) + paletteStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(border)).Padding(0, 1) + composerStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(border)).Padding(0, 1) + activityStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).BorderLeft(true).BorderForeground(lipgloss.Color(border)).PaddingLeft(1) amberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(amber)) greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(green)) diff --git a/tui/theme_test.go b/tui/theme_test.go index 6826713..d0da158 100644 --- a/tui/theme_test.go +++ b/tui/theme_test.go @@ -48,6 +48,19 @@ func TestThemeContrastAgainstBlackCanvas(t *testing.T) { } } +func TestThemeContrastAcrossCockpitSurfaces(t *testing.T) { + for _, background := range []string{deep, surface, surfaceHi} { + for _, foreground := range []string{white, offWhite, muted, cyan, green, amber, red} { + if ratio := contrast(foreground, background); ratio < 4.5 { + t.Fatalf("%s has insufficient contrast on %s: %.2f", foreground, background, ratio) + } + } + } + if ratio := contrast(border, surface); ratio < 3 { + t.Fatalf("border contrast is too low on the primary surface: %.2f", ratio) + } +} + func TestMarkdownRolesKeepCyanForEmphasis(t *testing.T) { if *markdownStyles.Document.Color != white || *markdownStyles.Text.Color != white || *markdownStyles.Paragraph.Color != white { t.Fatal("ordinary markdown text is not white-first")