From 32d6a32b73aa602efeef415c1604cf088a9f1c93 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 29 May 2026 15:01:58 +0800 Subject: [PATCH] feat(tui): expand and prioritize footer rotating tips --- .changeset/footer-rotating-tips.md | 5 + .../src/tui/components/chrome/footer.ts | 120 +++++++++++++----- .../components/panels/footer-context.test.ts | 46 ++++++- 3 files changed, 141 insertions(+), 30 deletions(-) create mode 100644 .changeset/footer-rotating-tips.md diff --git a/.changeset/footer-rotating-tips.md b/.changeset/footer-rotating-tips.md new file mode 100644 index 0000000000..771a8a71d6 --- /dev/null +++ b/.changeset/footer-rotating-tips.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Expand the footer's rotating tips to surface more commands and shortcuts, featuring newer and important ones more prominently. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 9079da44e6..506c05d1d2 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -23,36 +23,100 @@ import { safeUsageRatio } from '#/utils/usage/usage-format'; const MAX_CWD_SEGMENTS = 3; -// Toolbar tips — rotates every 30s, shows 2 tips joined by " | " when -// space allows, falls back to 1. -const TIP_ROTATE_INTERVAL_MS = 30_000; +// Toolbar tips — rotates every 10s. Most tips are short and pair up (two +// joined by " | ") when space allows; tips flagged `solo` are long or +// important enough to take the whole slot on their own. A `priority` weight +// makes a tip recur more often in the rotation (default 1). Width is always +// the final arbiter (a pair that doesn't fit falls back to its first tip). +// +// This is deliberately code-level configuration: edit the interval and the +// TOOLBAR_TIPS array below to change what the footer advertises. +const TIP_ROTATE_INTERVAL_MS = 10_000; const TIP_SEPARATOR = ' | '; -const TOOLBAR_TIPS: readonly string[] = [ - 'shift+tab: plan mode', - '/yolo: toggle yolo', - 'ctrl+c: cancel', - '/help: show commands', - '/model: switch model', - '@: mention files', + +export interface ToolbarTip { + readonly text: string; + /** + * Long/important tips render on their own. They never pair with a + * neighbour and never appear as the second half of someone else's pair. + */ + readonly solo?: boolean; + /** + * Rotation weight: a higher value makes the tip recur more often. Defaults + * to 1. Used to give newer/important features more airtime. + */ + readonly priority?: number; +} + +const TOOLBAR_TIPS: readonly ToolbarTip[] = [ + { text: 'shift+tab: plan mode' }, + { text: '/model: switch model' }, + { text: 'ctrl+s: steer mid-turn', priority: 2 }, + { text: '/compact: compact context', priority: 2 }, + { text: 'ctrl+o: expand tool output' }, + { text: '/tasks: background tasks' }, + { text: 'shift+enter: newline' }, + { text: '/init: generate AGENTS.md', priority: 2 }, + { text: '@: mention files' }, + { text: 'ctrl+c: cancel' }, + { text: '/theme: switch theme' }, + { text: '/auto: auto permission mode' }, + { text: '/yolo: toggle yolo' }, + { text: '/help: show commands' }, + { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, + { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, ]; -function currentTipIndex(): number { - return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS); +/** + * Expand tips into a rotation sequence using smooth weighted round-robin + * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while + * staying evenly spread, so a tip generally does not land next to its own + * duplicate. Deterministic and computed once at module load. Exported for + * unit testing. + */ +export function buildWeightedTips(tips: readonly ToolbarTip[]): readonly ToolbarTip[] { + const items = tips.map((t) => ({ + tip: t, + weight: Math.max(1, Math.trunc(t.priority ?? 1)), + current: 0, + })); + const total = items.reduce((sum, it) => sum + it.weight, 0); + const seq: ToolbarTip[] = []; + for (let n = 0; n < total; n++) { + let best = items[0]!; + for (const it of items) { + it.current += it.weight; + if (it.current > best.current) best = it; + } + best.current -= total; + seq.push(best.tip); + } + return seq; } -function twoRotatingTips(index: number): string { - const n = TOOLBAR_TIPS.length; - if (n === 0) return ''; - if (n === 1) return TOOLBAR_TIPS[0]!; - const offset = ((index % n) + n) % n; - return TOOLBAR_TIPS[offset]! + TIP_SEPARATOR + TOOLBAR_TIPS[(offset + 1) % n]!; +const ROTATION: readonly ToolbarTip[] = buildWeightedTips(TOOLBAR_TIPS); + +function currentTipIndex(): number { + return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS); } -function oneRotatingTip(index: number): string { - const n = TOOLBAR_TIPS.length; - if (n === 0) return ''; +/** + * Pick the tip(s) for a rotation index over the weighted ROTATION sequence. + * `primary` is always shown when it fits; `pair` (primary + next tip joined + * by the separator) is offered for wide terminals. Pairing is skipped when + * the current/next tip is `solo` or when the neighbour is a duplicate of the + * current tip (which can happen at the wrap boundary), keeping long/important + * tips on their own and avoiding "X | X". + */ +function tipsForIndex(index: number): { primary: string; pair: string | null } { + const n = ROTATION.length; + if (n === 0) return { primary: '', pair: null }; const offset = ((index % n) + n) % n; - return TOOLBAR_TIPS[offset]!; + const current = ROTATION[offset]!; + if (n === 1 || current.solo) return { primary: current.text, pair: null }; + const next = ROTATION[(offset + 1) % n]!; + if (next.solo || next.text === current.text) return { primary: current.text, pair: null }; + return { primary: current.text, pair: current.text + TIP_SEPARATOR + next.text }; } function shortenModel(model: string): string { @@ -214,16 +278,14 @@ export class FooterComponent implements Component { const leftWidth = visibleWidth(leftLine); // Rotating hint tips, fill remaining space on line 1. - const tipIndex = currentTipIndex(); - const tipTwo = twoRotatingTips(tipIndex); - const tipOne = oneRotatingTip(tipIndex); + const { primary, pair } = tipsForIndex(currentTipIndex()); const gap = 2; const remaining = Math.max(0, width - leftWidth - gap); let tipText = ''; - if (tipTwo && visibleWidth(tipTwo) <= remaining) { - tipText = tipTwo; - } else if (tipOne && visibleWidth(tipOne) <= remaining) { - tipText = tipOne; + if (pair && visibleWidth(pair) <= remaining) { + tipText = pair; + } else if (primary && visibleWidth(primary) <= remaining) { + tipText = primary; } let line1: string; diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index 7d14815c2d..db34ee8954 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import chalk from 'chalk'; -import { FooterComponent, formatFooterGitBadge } from '#/tui/components/chrome/footer'; +import { FooterComponent, formatFooterGitBadge, buildWeightedTips } from '#/tui/components/chrome/footer'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -146,3 +146,47 @@ describe('FooterComponent — context NaN resilience', () => { } }); }); + +describe('buildWeightedTips — weighted rotation', () => { + it('repeats higher-priority tips more often (length = sum of weights)', () => { + const seq = buildWeightedTips([ + { text: 'a' }, // weight 1 (default) + { text: 'b', priority: 3 }, + { text: 'c', priority: 2 }, + ]); + + const count = (t: string) => seq.filter((x) => x.text === t).length; + expect(seq).toHaveLength(6); + expect(count('a')).toBe(1); + expect(count('b')).toBe(3); + expect(count('c')).toBe(2); + expect(count('b')).toBeGreaterThan(count('a')); + }); + + it('keeps duplicates spread out — no tip sits next to itself', () => { + const seq = buildWeightedTips([ + { text: 'a' }, + { text: 'b', priority: 3 }, + { text: 'c', priority: 2 }, + ]); + + for (let i = 1; i < seq.length; i++) { + expect(seq[i]!.text).not.toBe(seq[i - 1]!.text); + } + }); + + it('preserves array order when all weights are the default (1)', () => { + const seq = buildWeightedTips([{ text: 'x' }, { text: 'y' }, { text: 'z' }]); + expect(seq.map((t) => t.text)).toEqual(['x', 'y', 'z']); + }); + + it('clamps non-positive / fractional priorities to a weight of at least 1', () => { + const seq = buildWeightedTips([ + { text: 'a', priority: 0 }, + { text: 'b', priority: -5 }, + { text: 'c', priority: 1.9 }, + ]); + expect(seq).toHaveLength(3); + expect(seq.map((t) => t.text).toSorted()).toEqual(['a', 'b', 'c']); + }); +});