Skip to content
Draft
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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]

steps:
- name: Checkout Code
Expand Down Expand Up @@ -36,6 +42,7 @@ jobs:

- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v6
if: matrix.os == 'ubuntu-latest'
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ npm install -g doro-cli

Once installed, just run `doro` in your terminal!

### Windows

```powershell
npm.cmd install -g doro-cli
doro.cmd
```

Use the `.cmd` launchers in PowerShell to avoid script execution policy restrictions.

## Quick Start (Build from source)

```bash
Expand Down
105 changes: 104 additions & 1 deletion src/__tests__/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ vi.mock('blessed', () => {
return {
default: {
screen: vi.fn(() => mockScreen),
box: vi.fn(() => ({ ...mockBox }))
box: vi.fn((options) => ({ ...mockBox, style: { ...options.style } }))
}
};
});
Expand Down Expand Up @@ -74,6 +74,109 @@ describe('DoroUi', () => {
expect(handlers.onAnyClick).toHaveBeenCalledTimes(1);
});

it('should use an ANSI-safe magenta palette on Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });

try {
ui = new DoroUi(handlers as any);
expect(vi.mocked(blessed.box).mock.calls[0][0]).toMatchObject({
style: { bg: 'magenta' }
});
expect(vi.mocked(blessed.box).mock.calls[1][0]).toMatchObject({
style: { bg: 'brightmagenta' }
});
expect(vi.mocked(blessed.box).mock.calls[2][0]).toMatchObject({
style: { bg: 'default', fg: 'default' }
});
} finally {
ui?.destroy();
Object.defineProperty(process, 'platform', { value: originalPlatform });
}
});

it('should use an ANSI-safe yellow palette for calm mode on Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });

try {
ui = new DoroUi(handlers as any, 'calm');
expect(vi.mocked(blessed.box).mock.calls[0][0]).toMatchObject({
style: { bg: 'yellow' }
});
expect(vi.mocked(blessed.box).mock.calls[1][0]).toMatchObject({
style: { bg: 'brightyellow' }
});
} finally {
ui?.destroy();
Object.defineProperty(process, 'platform', { value: originalPlatform });
}
});

it('should use distinct ANSI colors for Windows short and long breaks', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });

try {
ui = new DoroUi(handlers as any);
const root = vi.mocked(blessed.box).mock.results[0].value;
const progressFill = vi.mocked(blessed.box).mock.results[1].value;
const banner = vi.mocked(blessed.box).mock.results[2].value;
const baseState = {
status: 'running' as const,
remainingSeconds: 600,
durationSeconds: 1500,
isLocked: false,
volumeMode: 'normal' as const,
hasPrompt: false,
promptCountdownSeconds: 0,
promptTotalSeconds: 0,
promptNextMode: null,
updatePromptState: 'none' as const,
updateCheckResult: null,
editDurationState: 'none' as const,
editDurationValue: null,
editDurationBlink: false
};

ui.render({ ...baseState, mode: 'short' });
expect(root.style.bg).toBe('cyan');
expect(progressFill.style.bg).toBe('brightcyan');
expect(banner.setContent).toHaveBeenCalledWith(expect.stringContaining('{bright-cyan-bg}'));

ui.render({ ...baseState, mode: 'long' });
expect(root.style.bg).toBe('blue');
expect(progressFill.style.bg).toBe('brightblue');
expect(banner.setContent).toHaveBeenLastCalledWith(
expect.stringContaining('{bright-blue-bg}')
);

ui.destroy();
const previousBoxCount = vi.mocked(blessed.box).mock.results.length;
ui = new DoroUi(handlers as any, 'calm');
const calmBoxes = vi
.mocked(blessed.box)
.mock.results.slice(previousBoxCount, previousBoxCount + 3);
const calmRoot = calmBoxes[0].value;
const calmProgressFill = calmBoxes[1].value;
const calmBanner = calmBoxes[2].value;

ui.render({ ...baseState, mode: 'short' });
expect(calmRoot.style.bg).toBe('green');
expect(calmProgressFill.style.bg).toBe('brightgreen');

ui.render({ ...baseState, mode: 'long' });
expect(calmRoot.style.bg).toBe('cyan');
expect(calmProgressFill.style.bg).toBe('brightcyan');
expect(calmBanner.setContent).toHaveBeenLastCalledWith(
expect.stringContaining('{bright-cyan-bg}')
);
} finally {
ui?.destroy();
Object.defineProperty(process, 'platform', { value: originalPlatform });
}
});

it('should render work mode state', () => {
ui = new DoroUi(handlers as any);
const mockScreen = vi.mocked(blessed.screen).mock.results[0].value;
Expand Down
20 changes: 18 additions & 2 deletions src/__tests__/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,23 @@ describe('update functionality', () => {
});

describe('getUpdateCommand', () => {
it('should return npm install command', () => {
expect(getUpdateCommand()).toBe('npm install -g doro-cli@latest && doro');
it('should return a shell-compatible update command', () => {
expect(getUpdateCommand()).toBe(
process.platform === 'win32'
? 'npm.cmd install -g doro-cli@latest; if ($?) { doro.cmd }'
: 'npm install -g doro-cli@latest && doro'
);
});

it('should return a PowerShell-compatible command on Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });

try {
expect(getUpdateCommand()).toBe('npm.cmd install -g doro-cli@latest; if ($?) { doro.cmd }');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform });
}
});
});

Expand Down Expand Up @@ -440,6 +455,7 @@ describe('update functionality', () => {
const result = await resultPromise;

expect(result.success).toBe(true);
expect(result.command).toBe('npm.cmd install -g doro-cli@latest; if ($?) { doro.cmd }');
expect(mockSpawnLocal).toHaveBeenCalledWith('clip', []);

Object.defineProperty(process, 'platform', { value: originalPlatform });
Expand Down
76 changes: 64 additions & 12 deletions src/ui.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import blessed from 'blessed';
import { MODE_LABELS, MODE_LABELS_SHORT, type TimerMode, type TimerStatus } from './constants';
import { enableMouse, disableMouse } from './mouse';
import { getUpdateCommand } from './update';
import type { UpdateCheckResult, UpdatePromptState } from './update';

type UiRenderState = {
Expand Down Expand Up @@ -79,6 +80,10 @@ export function splitAtVisible(s: string, n: number): [string, string] {
return [s, ''];
}

function colorTagName(color: string): string {
return color.replace(/^(bright|light)(?=[a-z])/, '$1-');
}

// Wide format: [key, remaining letters] pairs — key rendered in bold, rest appended.
// Lock uses uppercase 'L' and Update uses uppercase 'U' to distinguish them from 'l'/'u'.
const HELP_KEYS_WIDE: [string, string][] = [
Expand Down Expand Up @@ -236,19 +241,28 @@ function getUpdatePromptText(
}

if (updatePromptState === 'copyFallback') {
const candidates = [
'Run this command after doro exits: npm install -g doro-cli@latest && doro',
'Run after exit: npm install -g doro-cli@latest && doro',
'Run: npm install -g doro-cli@latest && doro',
'Run: npm i -g doro-cli@latest && doro',
'npm i -g doro-cli@latest && doro'
];
const updateCommand = getUpdateCommand();
const candidates =
process.platform === 'win32'
? [
`Run this command after doro exits: ${updateCommand}`,
`Run after exit: ${updateCommand}`,
`Run: ${updateCommand}`,
updateCommand
]
: [
'Run this command after doro exits: npm install -g doro-cli@latest && doro',
'Run after exit: npm install -g doro-cli@latest && doro',
'Run: npm install -g doro-cli@latest && doro',
'Run: npm i -g doro-cli@latest && doro',
'npm i -g doro-cli@latest && doro'
];
for (const c of candidates) {
if (c.length <= cols) {
return c;
}
}
return 'npm i -g doro-cli@latest && doro';
return updateCommand;
}

if (updatePromptState === 'skipped') {
Expand Down Expand Up @@ -349,12 +363,15 @@ function buildProgressRow(

const bO = bold ? '{bold}' : '';
const bC = bold ? '{/bold}' : '';
const fillTag = colorTagName(fillBg);
const baseTag = colorTagName(baseBg);
const fgTag = colorTagName(fg);
let content = '';
if (p1.length > 0) {
content += `{${fillBg}-bg}{${fg}-fg}${bO}${p1}${bC}`;
content += `{${fillTag}-bg}{${fgTag}-fg}${bO}${p1}${bC}`;
}
if (p2.length > 0) {
content += `{${baseBg}-bg}{${fg}-fg}${bO}${p2}${bC}`;
content += `{${baseTag}-bg}{${fgTag}-fg}${bO}${p2}${bC}`;
}
return content;
}
Expand Down Expand Up @@ -454,6 +471,40 @@ const PALETTES: Record<ColorScheme, Palette> = {
}
};

function createWindowsModeStyle(base: string, fill: string): ModeStyle {
return {
base,
fill,
text: 'default',
bannerBg: 'default',
bannerFg: 'default',
statusBg: 'default',
statusFg: 'default',
promptBg: 'default',
promptFg: 'default'
};
}

const WINDOWS_PAUSED_STYLE = createWindowsModeStyle('default', 'brightblack');
const WINDOWS_PALETTES: Record<ColorScheme, Palette> = {
modern: {
modes: {
work: createWindowsModeStyle('magenta', 'brightmagenta'),
short: createWindowsModeStyle('cyan', 'brightcyan'),
long: createWindowsModeStyle('blue', 'brightblue')
},
pause: WINDOWS_PAUSED_STYLE
},
calm: {
modes: {
work: createWindowsModeStyle('yellow', 'brightyellow'),
short: createWindowsModeStyle('green', 'brightgreen'),
long: createWindowsModeStyle('cyan', 'brightcyan')
},
pause: WINDOWS_PAUSED_STYLE
}
};

function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
Expand Down Expand Up @@ -506,7 +557,8 @@ export class DoroUi {

public constructor(handlers: UiHandlers, initialColorScheme: ColorScheme = 'modern') {
this.colorScheme = initialColorScheme;
const initialStyle = PALETTES[this.colorScheme].modes.work;
const palettes = process.platform === 'win32' ? WINDOWS_PALETTES : PALETTES;
const initialStyle = palettes[this.colorScheme].modes.work;

this.screen = blessed.screen({
smartCSR: false,
Expand Down Expand Up @@ -644,7 +696,7 @@ export class DoroUi {
const cols = this.screen.cols;
const rows = this.screen.rows;
const isPaused = state.status === 'paused';
const palette = PALETTES[this.colorScheme];
const palette = (process.platform === 'win32' ? WINDOWS_PALETTES : PALETTES)[this.colorScheme];
const isTransition = state.hasPrompt;
const hasUpdatePrompt = state.updatePromptState !== 'none';
const isEditingDuration = state.editDurationState === 'editing';
Expand Down
4 changes: 4 additions & 0 deletions src/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ export async function checkForUpdates(): Promise<UpdateCheckResult> {
* Gets the update command for the current platform
*/
export function getUpdateCommand(): string {
if (process.platform === 'win32') {
return 'npm.cmd install -g doro-cli@latest; if ($?) { doro.cmd }';
}

return 'npm install -g doro-cli@latest && doro';
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading