Skip to content

Commit 3eb8a66

Browse files
Merge pull request #80 from ai-action/feat/theme
2 parents 1409954 + c2c8e5b commit 3eb8a66

43 files changed

Lines changed: 1361 additions & 303 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Single-test examples:
4747
- TypeScript is `strict`; avoid implicit `any`
4848
- Use barrel files (`index.ts`) to consolidate related exports
4949
- Use `ink-testing-library` in tests and avoid `act()`
50+
- For Ink keyboard interaction tests, avoid mocking `ink` low-level input hooks and prefer `render(...).stdin.write(...)`
5051
- Use `// v8 ignore` in tests to exclude unreachable entrypoint guards; use `vi.hoisted()` for mock variables accessed by `vi.mock()` hoisted scopes
5152
- Use Conventional Commits: type(scope): description
5253
- Create PR with `.github/PULL_REQUEST_TEMPLATE.md`

src/components/App.test.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const deleteSession = vi.hoisted(() => vi.fn());
2828
const deleteSessionIfEmpty = vi.hoisted(() => vi.fn());
2929
const appendMessage = vi.hoisted(() => vi.fn());
3030
const updateSessionModel = vi.hoisted(() => vi.fn());
31+
const saveConfig = vi.hoisted(() => vi.fn());
3132

3233
vi.mock('../utils', async () => ({
3334
...(await vi.importActual('../utils')),
@@ -39,8 +40,9 @@ vi.mock('../utils', async () => ({
3940
host: 'http://localhost:11434',
4041
model: 'gemma4',
4142
searxngBaseUrl: undefined,
43+
theme: 'github-dark',
4244
})),
43-
saveConfig: vi.fn(),
45+
saveConfig,
4446
},
4547
screen: {
4648
clear: clearScreen,
@@ -65,6 +67,8 @@ const capturedCallbacks = vi.hoisted(() => ({
6567
onModeChange: null as ((mode: string) => void) | null,
6668
onSelect: null as ((update: { model: string }) => void) | null,
6769
onSaveSearch: null as ((update: { searxngBaseUrl?: string }) => void) | null,
70+
onPreviewTheme: null as ((themeId: string) => void) | null,
71+
onSaveTheme: null as ((themeId: string) => void) | null,
6872
onClose: null as (() => void) | null,
6973
onToggleMode: null as (() => void) | null,
7074
onOpenSession: null as ((sessionId: string) => void) | null,
@@ -133,6 +137,24 @@ vi.mock('./SearchSettings', () => ({
133137
},
134138
}));
135139

140+
vi.mock('./ThemeSettings', () => ({
141+
ThemeSettings: ({
142+
onClose,
143+
onPreview,
144+
onSave,
145+
}: {
146+
currentTheme: string;
147+
onClose: () => void;
148+
onPreview: (themeId: string) => void;
149+
onSave: (themeId: string) => void;
150+
}) => {
151+
capturedCallbacks.onClose = onClose;
152+
capturedCallbacks.onPreviewTheme = onPreview;
153+
capturedCallbacks.onSaveTheme = onSave;
154+
return <Text>ThemeSettings</Text>;
155+
},
156+
}));
157+
136158
vi.mock('./Footer', () => ({
137159
Footer: ({
138160
mode,
@@ -179,6 +201,8 @@ describe('App', () => {
179201
capturedCallbacks.onModeChange = null;
180202
capturedCallbacks.onSelect = null;
181203
capturedCallbacks.onSaveSearch = null;
204+
capturedCallbacks.onPreviewTheme = null;
205+
capturedCallbacks.onSaveTheme = null;
182206
capturedCallbacks.onClose = null;
183207
capturedCallbacks.onToggleMode = null;
184208
capturedCallbacks.onOpenSession = null;
@@ -197,6 +221,7 @@ describe('App', () => {
197221
deleteSessionIfEmpty.mockReset();
198222
appendMessage.mockReset();
199223
updateSessionModel.mockReset();
224+
saveConfig.mockReset();
200225

201226
let counter = 0;
202227
createSession.mockImplementation((model: string) => ({
@@ -305,6 +330,14 @@ describe('App', () => {
305330
expect(lastFrame()).toContain('SearchSettings');
306331
});
307332

333+
it('shows ThemeSettings when /theme command is issued', async () => {
334+
const { lastFrame, rerender } = render(<App />);
335+
capturedCallbacks.onCommand?.('/theme');
336+
rerender(<App />);
337+
await time.tick();
338+
expect(lastFrame()).toContain('ThemeSettings');
339+
});
340+
308341
it('returns to chat when search settings are saved', async () => {
309342
const { lastFrame, rerender } = render(<App />);
310343
capturedCallbacks.onCommand?.('/search');
@@ -327,6 +360,37 @@ describe('App', () => {
327360
expect(lastFrame()).not.toContain('ModelPicker');
328361
});
329362

363+
it('previews and saves a theme', async () => {
364+
const { lastFrame, rerender } = render(<App />);
365+
capturedCallbacks.onCommand?.('/theme');
366+
rerender(<App />);
367+
await time.tick();
368+
369+
capturedCallbacks.onPreviewTheme?.('dracula');
370+
capturedCallbacks.onSaveTheme?.('dracula');
371+
rerender(<App />);
372+
await time.tick();
373+
374+
expect(saveConfig).toHaveBeenCalledWith({ theme: 'dracula' });
375+
expect(lastFrame()).not.toContain('ThemeSettings');
376+
});
377+
378+
it('restores chat without saving when theme settings close', async () => {
379+
const { lastFrame, rerender } = render(<App />);
380+
capturedCallbacks.onCommand?.('/theme');
381+
rerender(<App />);
382+
await time.tick();
383+
384+
capturedCallbacks.onPreviewTheme?.('nord');
385+
capturedCallbacks.onClose?.();
386+
rerender(<App />);
387+
await time.tick();
388+
389+
expect(saveConfig).not.toHaveBeenCalled();
390+
expect(lastFrame()).not.toContain('ThemeSettings');
391+
expect(lastFrame()).toContain('>');
392+
});
393+
330394
it('calls exit when /exit command is issued', () => {
331395
render(<App />);
332396
capturedCallbacks.onCommand?.('/exit');

src/components/App.tsx

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Box, useApp } from 'ink';
22
import { useCallback, useEffect, useRef, useState } from 'react';
33

4-
import { MODE } from '../constants';
5-
import type { Config, Mode } from '../types';
4+
import { MODE, THEME } from '../constants';
5+
import type { Config, Mode, ThemeId } from '../types';
66
import { agents, config, ollama, screen, session, terminal } from '../utils';
77
import { Chat } from './Chat';
88
import { Footer } from './Footer';
@@ -11,12 +11,14 @@ import { TURN_ABORTED_MESSAGE } from './Messages/constants';
1111
import { ModelPicker } from './ModelPicker';
1212
import { SearchSettings } from './SearchSettings';
1313
import { SessionManager } from './SessionManager';
14+
import { ThemeSettings } from './ThemeSettings';
1415

1516
enum SCREEN {
1617
CHAT = 'chat',
1718
MODEL_PICKER = 'model-picker',
1819
SEARCH_SETTINGS = 'search-settings',
1920
SESSION_MANAGER = 'session-manager',
21+
THEME_SETTINGS = 'theme-settings',
2022
}
2123

2224
interface Props {
@@ -35,18 +37,26 @@ function createSession(
3537
export function App({ sessionId }: Props) {
3638
const { exit } = useApp();
3739
const [appConfig, setConfig] = useState(() => config.loadConfig());
40+
const [previewThemeId, setPreviewThemeId] = useState<ThemeId | null>(null);
3841
const [currentScreen, setScreen] = useState<SCREEN>(SCREEN.CHAT);
3942
const [mode, setMode] = useState<Mode>(MODE.SAFE);
4043
const [activeSession, setSession] = useState(() =>
4144
createSession(sessionId, config.loadConfig().model),
4245
);
4346
const [isHeaderLoaded, setIsHeaderLoaded] = useState(false);
4447
const sessionRef = useRef(activeSession);
48+
const activeThemeId = previewThemeId ?? appConfig.theme;
49+
const activeTheme = THEME.getTheme(activeThemeId);
50+
const commandColorRef = useRef(activeTheme.colors.command);
4551

4652
useEffect(() => {
4753
sessionRef.current = activeSession;
4854
}, [activeSession]);
4955

56+
useEffect(() => {
57+
commandColorRef.current = activeTheme.colors.command;
58+
}, [activeTheme.colors.command]);
59+
5060
useEffect(() => {
5161
return () => {
5262
const currentSession = sessionRef.current;
@@ -55,7 +65,7 @@ export function App({ sessionId }: Props) {
5565
if (!deleted && currentSession.messages.length > 0) {
5666
const resumeCommand = `code-ollama resume ${currentSession.metadata.id}`;
5767
terminal.write(
58-
`Resume session: ${terminal.color(resumeCommand, 'cyan')}\n`,
68+
`Resume session: ${terminal.color(resumeCommand, commandColorRef.current)}\n`,
5969
);
6070
}
6171
};
@@ -156,6 +166,11 @@ export function App({ sessionId }: Props) {
156166
setScreen(SCREEN.SEARCH_SETTINGS);
157167
break;
158168

169+
case '/theme':
170+
setPreviewThemeId(appConfig.theme);
171+
setScreen(SCREEN.THEME_SETTINGS);
172+
break;
173+
159174
case '/clear': {
160175
agents.resetSystemMessage();
161176
setScreen(SCREEN.CHAT);
@@ -170,7 +185,7 @@ export function App({ sessionId }: Props) {
170185
break;
171186
}
172187
},
173-
[appConfig.model, exit, setActiveSession],
188+
[appConfig.model, appConfig.theme, exit, setActiveSession],
174189
);
175190

176191
const handleUpdateConfig = useCallback((update: Partial<Config>) => {
@@ -195,6 +210,23 @@ export function App({ sessionId }: Props) {
195210
setScreen(SCREEN.CHAT);
196211
}, []);
197212

213+
const handleThemePreview = useCallback((themeId: ThemeId) => {
214+
setPreviewThemeId(themeId);
215+
}, []);
216+
217+
const handleThemeClose = useCallback(() => {
218+
setPreviewThemeId(null);
219+
setScreen(SCREEN.CHAT);
220+
}, []);
221+
222+
const handleThemeSave = useCallback(
223+
(themeId: ThemeId) => {
224+
setPreviewThemeId(null);
225+
handleUpdateConfig({ theme: themeId });
226+
},
227+
[handleUpdateConfig],
228+
);
229+
198230
const handleToggleMode = useCallback(() => {
199231
setMode((mode) => {
200232
// Cycle: safe -> auto -> plan -> safe
@@ -221,6 +253,7 @@ export function App({ sessionId }: Props) {
221253
currentModel={appConfig.model}
222254
onSelect={handleUpdateConfig}
223255
onClose={handleClose}
256+
theme={activeTheme}
224257
/>
225258
);
226259
break;
@@ -231,6 +264,7 @@ export function App({ sessionId }: Props) {
231264
currentUrl={appConfig.searxngBaseUrl}
232265
onSave={handleUpdateConfig}
233266
onClose={handleClose}
267+
theme={activeTheme}
234268
/>
235269
);
236270
break;
@@ -243,6 +277,18 @@ export function App({ sessionId }: Props) {
243277
onDelete={handleDeleteSession}
244278
onNew={handleCreateSession}
245279
onOpen={handleOpenSession}
280+
theme={activeTheme}
281+
/>
282+
);
283+
break;
284+
285+
case SCREEN.THEME_SETTINGS:
286+
screenContent = (
287+
<ThemeSettings
288+
currentTheme={appConfig.theme}
289+
onClose={handleThemeClose}
290+
onPreview={handleThemePreview}
291+
onSave={handleThemeSave}
246292
/>
247293
);
248294
break;
@@ -257,21 +303,27 @@ export function App({ sessionId }: Props) {
257303
mode={mode}
258304
onModeChange={setMode}
259305
sessionId={activeSession.metadata.id}
306+
theme={activeTheme}
260307
/>
261308
);
262309
break;
263310
}
264311

265312
return (
266313
<Box flexDirection="column">
267-
<Header model={appConfig.model} onLoad={handleHeaderLoad} />
314+
<Header
315+
model={appConfig.model}
316+
onLoad={handleHeaderLoad}
317+
theme={activeTheme}
318+
/>
268319

269320
{isHeaderLoaded && screenContent}
270321

271322
<Footer
272323
mode={mode}
273324
model={appConfig.model}
274325
onToggleMode={handleToggleMode}
326+
theme={activeTheme}
275327
/>
276328
</Box>
277329
);

src/components/Chat/Chat.test.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Text } from 'ink';
22
import { render } from 'ink-testing-library';
33

4-
import { DECISION, MODE } from '../../constants';
4+
import { DECISION, MODE, THEME } from '../../constants';
55
import type { Decision } from '../../types';
66
import { ollama, time, tools } from '../../utils';
77
import { prewarmCodeBlocks } from '../CodeBlock';
@@ -317,9 +317,45 @@ describe('Chat', () => {
317317
await waitForStream();
318318
expect(vi.mocked(prewarmCodeBlocks)).toHaveBeenCalledWith(
319319
'Here:\n```ts\nconst x = 1;\n```',
320+
THEME.getTheme(),
320321
);
321322
}, 10_000);
322323

324+
it('formats complete markdown while the assistant is still streaming', async () => {
325+
let resumeStream: (() => void) | undefined;
326+
327+
vi.mocked(ollama.streamChat).mockImplementationOnce(async function* () {
328+
yield { type: 'content', content: 'Use **important**' };
329+
await new Promise<void>((resolve) => {
330+
resumeStream = resolve;
331+
});
332+
yield { type: 'content', content: ' text' };
333+
});
334+
335+
const chat = (
336+
<Chat
337+
model="gemma4"
338+
onCommand={vi.fn()}
339+
mode={MODE.SAFE}
340+
onModeChange={onModeChange}
341+
sessionId="0"
342+
/>
343+
);
344+
const { lastFrame, rerender } = render(chat);
345+
await time.tick();
346+
submitInput('format this');
347+
rerender(chat);
348+
await time.tick();
349+
350+
const streamingFrame = lastFrame() ?? '';
351+
expect(streamingFrame).toContain('Use important');
352+
expect(streamingFrame).not.toContain('**important**');
353+
354+
resumeStream?.();
355+
await waitForStream();
356+
expect(lastFrame()).toContain('Use important text');
357+
}, 10_000);
358+
323359
it('calls onCommand when a slash command is submitted', async () => {
324360
const onCommand = vi.fn();
325361
const chat = (

0 commit comments

Comments
 (0)