Skip to content

Commit ed9b250

Browse files
authored
feat(ui): add tip bar with contextual usage tips (#119)
* feat(ui): add tip bar with contextual usage tips Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(ui): address code review issues in tip bar feature Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(tips): add static images tip pointing to Settings Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> --------- Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent 4e1b3af commit ed9b250

8 files changed

Lines changed: 602 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import type { OnboardingStage } from './view/onboarding/index';
2929
import { HistoryPanel } from './components/HistoryPanel';
3030
import { ModelPickerPanel } from './components/ModelPickerPanel';
3131
import { ImagePreviewModal } from './components/ImagePreviewModal';
32+
import { TipBar } from './components/TipBar';
33+
import { useTips } from './hooks/useTips';
3234
import type { AttachedImage } from './types/image';
3335
import { MAX_IMAGE_SIZE_BYTES } from './types/image';
3436
import { useConfig } from './contexts/ConfigContext';
@@ -299,6 +301,11 @@ function App() {
299301
*/
300302
const canSave = !isGenerating && messages.some((m) => m.role === 'assistant');
301303
const shouldRenderOverlay = overlayState === 'visible';
304+
const {
305+
tip: activeTip,
306+
tipKey,
307+
isVisible: isTipVisible,
308+
} = useTips(shouldRenderOverlay);
302309

303310
/**
304311
* Reference stored for ResizeObserver cleanup.
@@ -1897,6 +1904,26 @@ function App() {
18971904
shake={shakeAskBar}
18981905
maxImages={config.window.maxImages}
18991906
/>
1907+
{/* Tip bar: ask-bar mode only. The !isChatMode gate lives
1908+
OUTSIDE AnimatePresence for the same reason as the history
1909+
panel above: prevents two simultaneous ResizeObserver
1910+
setSize() calls (jitter) when isChatMode transitions. */}
1911+
{!isChatMode && (
1912+
<AnimatePresence>
1913+
{isTipVisible && (
1914+
<motion.div
1915+
key="tip-bar"
1916+
initial={{ height: 0, opacity: 0 }}
1917+
animate={{ height: 'auto', opacity: 1 }}
1918+
exit={{ height: 0, opacity: 0 }}
1919+
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
1920+
style={{ overflow: 'hidden' }}
1921+
>
1922+
<TipBar tip={activeTip} tipKey={tipKey} />
1923+
</motion.div>
1924+
)}
1925+
</AnimatePresence>
1926+
)}
19001927
</div>
19011928

19021929
{/* Chat-mode model picker dropdown - floating card identical in style

src/__tests__/App.test.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, screen, fireEvent, act } from '@testing-library/react';
2-
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
33
import App from '../App';
44
import { DEFAULT_CONFIG } from '../contexts/ConfigContext';
55
import {
@@ -10,6 +10,11 @@ import {
1010
getLastChannel,
1111
} from '../testUtils/mocks/tauri';
1212
import { __mockWindow } from '../testUtils/mocks/tauri-window';
13+
import { useTips } from '../hooks/useTips';
14+
15+
vi.mock('../hooks/useTips', () => ({
16+
useTips: vi.fn(() => ({ tip: '', tipKey: 0, isVisible: false })),
17+
}));
1318

1419
async function showOverlay(selectedText: string | null = null) {
1520
await act(async () => {
@@ -5349,4 +5354,62 @@ describe('App', () => {
53495354
expect(screen.queryByText("You're all set")).toBeNull();
53505355
});
53515356
});
5357+
5358+
describe('tip bar', () => {
5359+
afterEach(() => {
5360+
vi.mocked(useTips).mockReturnValue({
5361+
tip: '',
5362+
tipKey: 0,
5363+
isVisible: false,
5364+
});
5365+
});
5366+
5367+
it('renders TipBar when useTips returns isVisible=true', async () => {
5368+
vi.mocked(useTips).mockReturnValue({
5369+
tip: 'Capture a screenshot with /screen',
5370+
tipKey: 1,
5371+
isVisible: true,
5372+
});
5373+
render(<App />);
5374+
await showOverlay();
5375+
expect(screen.getByTestId('tip-text')).toBeInTheDocument();
5376+
});
5377+
5378+
it('does not render TipBar when useTips returns isVisible=false', async () => {
5379+
render(<App />);
5380+
await showOverlay();
5381+
expect(screen.queryByTestId('tip-text')).not.toBeInTheDocument();
5382+
});
5383+
5384+
it('hides TipBar in chat mode even when isVisible=true', async () => {
5385+
vi.mocked(useTips).mockReturnValue({
5386+
tip: 'Test tip',
5387+
tipKey: 1,
5388+
isVisible: true,
5389+
});
5390+
enableChannelCaptureWithResponses({
5391+
get_model_picker_state: {
5392+
active: 'gemma4:e2b',
5393+
all: ['gemma4:e2b'],
5394+
ollamaReachable: true,
5395+
},
5396+
});
5397+
render(<App />);
5398+
await showOverlay();
5399+
const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
5400+
act(() => {
5401+
fireEvent.change(textarea, { target: { value: 'hello' } });
5402+
});
5403+
act(() => {
5404+
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
5405+
});
5406+
await act(async () => {});
5407+
act(() => {
5408+
getLastChannel()?.simulateMessage({ type: 'Token', data: 'hi' });
5409+
getLastChannel()?.simulateMessage({ type: 'Done' });
5410+
});
5411+
await act(async () => {});
5412+
expect(screen.queryByTestId('tip-text')).not.toBeInTheDocument();
5413+
});
5414+
});
53525415
});

src/components/TipBar.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
const NOISE_CHARS = '!@#$%^&*<>?/|abcdefghijklmnopqrstuvwxyz0123456789░▒';
4+
const CHAR_DELAY = 36;
5+
const FLICKER_MS = 40;
6+
const FLICKER_COUNT = 4;
7+
const FADE_MS = 280;
8+
9+
interface TipBarProps {
10+
tip: string;
11+
tipKey: number;
12+
}
13+
14+
export function TipBar({ tip, tipKey }: TipBarProps) {
15+
const spanRef = useRef<HTMLSpanElement>(null);
16+
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
17+
18+
useEffect(() => {
19+
const span = spanRef.current;
20+
/* v8 ignore start -- ref is always set post-mount */
21+
if (!span) return;
22+
/* v8 ignore stop */
23+
24+
timersRef.current.forEach(clearTimeout);
25+
timersRef.current = [];
26+
27+
const addTimer = (fn: () => void, ms: number) => {
28+
// eslint-disable-next-line @eslint-react/web-api-no-leaked-timeout
29+
const id = setTimeout(fn, ms);
30+
timersRef.current.push(id);
31+
};
32+
33+
const runTypewriter = () => {
34+
const chars = tip.split('');
35+
span.innerHTML = chars
36+
.map((_, i) => `<span data-ci="${i}"></span>`)
37+
.join('');
38+
39+
chars.forEach((ch, i) => {
40+
const el = span.querySelector<HTMLSpanElement>(`[data-ci="${i}"]`)!;
41+
42+
if (ch === ' ') {
43+
addTimer(() => {
44+
el.textContent = ' ';
45+
}, i * CHAR_DELAY);
46+
return;
47+
}
48+
49+
for (let f = 0; f < FLICKER_COUNT; f++) {
50+
addTimer(
51+
() => {
52+
/* v8 ignore next -- flicker color is visual-only */
53+
el.style.color = '#ff8d5c';
54+
el.textContent =
55+
NOISE_CHARS[Math.floor(Math.random() * NOISE_CHARS.length)];
56+
},
57+
i * CHAR_DELAY + f * FLICKER_MS,
58+
);
59+
}
60+
61+
addTimer(
62+
() => {
63+
/* v8 ignore next -- color reset is visual-only */
64+
el.style.color = '#8a8a8e';
65+
el.textContent = ch;
66+
},
67+
i * CHAR_DELAY + FLICKER_COUNT * FLICKER_MS,
68+
);
69+
});
70+
};
71+
72+
if (tipKey === 0) {
73+
runTypewriter();
74+
} else {
75+
/* v8 ignore start -- fade-out style transitions are visual-only */
76+
span.style.opacity = '0';
77+
span.style.filter = 'blur(4px)';
78+
span.style.transition = `opacity ${FADE_MS}ms ease, filter ${FADE_MS}ms ease`;
79+
/* v8 ignore stop */
80+
81+
addTimer(() => {
82+
/* v8 ignore start -- style reset before next tip is visual-only */
83+
span.style.transition = '';
84+
span.style.opacity = '';
85+
span.style.filter = '';
86+
/* v8 ignore stop */
87+
runTypewriter();
88+
}, FADE_MS);
89+
}
90+
91+
return () => {
92+
timersRef.current.forEach(clearTimeout);
93+
timersRef.current = [];
94+
};
95+
}, [tip, tipKey]);
96+
97+
return (
98+
<div
99+
className="flex items-center justify-center gap-1.5 border-t border-white/5 px-4 py-[5px]"
100+
data-testid="tip-bar"
101+
>
102+
<span className="text-[9px] font-bold tracking-widest uppercase text-[#ff8d5c] bg-[#ff8d5c]/10 rounded px-1.5 py-0.5 flex-shrink-0">
103+
TIP
104+
</span>
105+
<span
106+
ref={spanRef}
107+
className="text-[10px]"
108+
style={{ color: '#8a8a8e' }}
109+
data-testid="tip-text"
110+
/>
111+
</div>
112+
);
113+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { render, screen, act } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { TipBar } from '../TipBar';
4+
5+
describe('TipBar', () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
vi.spyOn(Math, 'random').mockReturnValue(0);
9+
});
10+
11+
afterEach(() => {
12+
vi.useRealTimers();
13+
vi.restoreAllMocks();
14+
});
15+
16+
it('renders the TIP badge', () => {
17+
render(<TipBar tip="Hello world" tipKey={0} />);
18+
expect(screen.getByText('TIP')).toBeInTheDocument();
19+
});
20+
21+
it('renders the tip-text span', () => {
22+
render(<TipBar tip="Hello world" tipKey={0} />);
23+
expect(screen.getByTestId('tip-text')).toBeInTheDocument();
24+
});
25+
26+
it('renders the strip container', () => {
27+
render(<TipBar tip="Test" tipKey={0} />);
28+
expect(screen.getByTestId('tip-bar')).toBeInTheDocument();
29+
});
30+
31+
it('reveals full tip text after animation completes (tipKey=0)', () => {
32+
render(<TipBar tip="Hi" tipKey={0} />);
33+
act(() => vi.advanceTimersByTime(5000));
34+
expect(screen.getByTestId('tip-text').textContent).toBe('Hi');
35+
});
36+
37+
it('handles space characters instantly without flicker', () => {
38+
render(<TipBar tip="a b" tipKey={0} />);
39+
act(() => vi.advanceTimersByTime(5000));
40+
expect(screen.getByTestId('tip-text').textContent).toBe('a b');
41+
});
42+
43+
it('re-animates and shows new tip after tipKey increments', () => {
44+
const { rerender } = render(<TipBar tip="Hello" tipKey={0} />);
45+
act(() => vi.advanceTimersByTime(5000));
46+
rerender(<TipBar tip="World" tipKey={1} />);
47+
act(() => vi.advanceTimersByTime(5000));
48+
expect(screen.getByTestId('tip-text').textContent).toBe('World');
49+
});
50+
51+
it('cleans up timers on unmount without throwing', () => {
52+
const { unmount } = render(<TipBar tip="Hello" tipKey={0} />);
53+
expect(() => unmount()).not.toThrow();
54+
});
55+
});

src/config/__tests__/tips.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { TIPS } from '../tips';
3+
4+
describe('TIPS', () => {
5+
it('is non-empty', () => {
6+
expect(TIPS.length).toBeGreaterThan(0);
7+
});
8+
9+
it('all strings are under 110 chars', () => {
10+
for (const tip of TIPS) {
11+
expect(tip.length).toBeLessThanOrEqual(110);
12+
}
13+
});
14+
15+
it('includes an images tip pointing to Settings', () => {
16+
const imagesTip = TIPS.find((t) => t.includes('Settings'));
17+
expect(imagesTip).toBeDefined();
18+
expect(imagesTip!.toLowerCase()).toContain('image');
19+
});
20+
});

src/config/tips.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const TIPS: readonly string[] = [
2+
'Use /screen to snap your display and attach it to the chat for visual context',
3+
'Highlight text in any app before summoning Thuki to include it as context',
4+
'/think makes Thuki reason step by step before answering, great for hard questions',
5+
'/search pulls live web results into the chat so answers stay current',
6+
'⌘W or Esc hides the window; Thuki keeps running in the background',
7+
'Drop an image onto the bar to attach it and ask questions about what you see',
8+
'Paste images from your clipboard directly; no need to save to disk first',
9+
'Click the chip icon to switch between any model you have installed in Ollama',
10+
'The bookmark icon saves the full conversation so you can come back to it later',
11+
'/translate converts your selected text to any language you specify',
12+
'Click the clock icon to browse all your past conversations',
13+
'Highlight any text and type /rewrite to get a cleaner, better-flowing version without changing the meaning',
14+
'/tldr condenses any highlighted or pasted block of text into 1-3 sentences',
15+
'/refine fixes grammar, spelling, and punctuation in highlighted text while keeping your voice and tone',
16+
'/bullets turns highlighted text or a pasted block into a concise bullet list of key points',
17+
'/todos scans highlighted text or notes and pulls out every action item as a checkbox list',
18+
'Type / in the ask bar to see all available commands and pick one with Tab',
19+
'Commands can combine in one message: try /screen /think to capture your screen and reason through it',
20+
'Everything runs locally through Ollama; your conversations never leave your machine',
21+
'Attach images to your messages for visual context; visit Settings to adjust the limit',
22+
];

0 commit comments

Comments
 (0)