soja-cli 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/services/preference-service.js +55 -0
- package/dist/cli/commands/tui.js +1 -1
- package/dist/config/config.js +2 -0
- package/dist/data/sync/finance.js +3 -0
- package/dist/ui/Shell.js +28 -14
- package/dist/ui/app-state.js +22 -4
- package/dist/ui/chrome/TopBar.js +3 -2
- package/dist/ui/hooks/use-commands.js +13 -9
- package/dist/ui/screens/FinanceScreen.js +36 -2
- package/dist/ui/screens/HelpScreen.js +2 -0
- package/dist/ui/screens/ProfileScreen.js +79 -0
- package/dist/ui/screens/SettingsScreen.js +77 -0
- package/package.json +1 -1
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
export const GLOBAL_SHORTCUTS = {
|
|
2
|
+
commands: ':',
|
|
3
|
+
search: '/',
|
|
4
|
+
newTask: 'n',
|
|
5
|
+
projects: 'p',
|
|
6
|
+
workspaces: 'w',
|
|
7
|
+
finance: 'f',
|
|
8
|
+
access: 'A',
|
|
9
|
+
chat: '#',
|
|
10
|
+
mouse: 'M',
|
|
11
|
+
help: '?',
|
|
12
|
+
settings: ',',
|
|
13
|
+
profile: 'u',
|
|
14
|
+
quit: 'q',
|
|
15
|
+
};
|
|
16
|
+
export function normalizeGlobalShortcut(value) {
|
|
17
|
+
const key = value.trim().toLowerCase();
|
|
18
|
+
if (/^\S$/u.test(key) && key.length === 1)
|
|
19
|
+
return key;
|
|
20
|
+
if (/^ctrl\+[a-z]$/.test(key) && key !== 'ctrl+r')
|
|
21
|
+
return key;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
export function matchesGlobalShortcut(input, key, binding) {
|
|
25
|
+
const normalized = normalizeGlobalShortcut(binding);
|
|
26
|
+
if (!normalized)
|
|
27
|
+
return false;
|
|
28
|
+
const control = /^ctrl\+([a-z])$/.exec(normalized);
|
|
29
|
+
if (control)
|
|
30
|
+
return key.ctrl && !key.meta && input.toLowerCase() === control[1];
|
|
31
|
+
return !key.ctrl && !key.meta && input === binding;
|
|
32
|
+
}
|
|
1
33
|
/** Per-machine interface preferences, kept in the config file. */
|
|
2
34
|
export class PreferenceService {
|
|
3
35
|
config;
|
|
@@ -14,4 +46,27 @@ export class PreferenceService {
|
|
|
14
46
|
if (current)
|
|
15
47
|
this.config.save({ ...current, mouse: on });
|
|
16
48
|
}
|
|
49
|
+
keyboardShortcuts() {
|
|
50
|
+
return { ...GLOBAL_SHORTCUTS, ...this.config.load()?.keyboardShortcuts };
|
|
51
|
+
}
|
|
52
|
+
setKeyboardShortcut(action, key) {
|
|
53
|
+
const normalized = normalizeGlobalShortcut(key);
|
|
54
|
+
if (!normalized)
|
|
55
|
+
throw new Error('Use one printable key or a Ctrl+letter combination. Ctrl+R is reserved for reset.');
|
|
56
|
+
const current = this.keyboardShortcuts();
|
|
57
|
+
const conflict = Object.keys(current).find((other) => other !== action && normalizeGlobalShortcut(current[other]) === normalized);
|
|
58
|
+
if (conflict)
|
|
59
|
+
throw new Error(`That key is already assigned to ${conflict}.`);
|
|
60
|
+
const config = this.config.load();
|
|
61
|
+
if (config)
|
|
62
|
+
this.config.save({ ...config, keyboardShortcuts: { ...config.keyboardShortcuts, [action]: normalized } });
|
|
63
|
+
}
|
|
64
|
+
resetKeyboardShortcuts() {
|
|
65
|
+
const config = this.config.load();
|
|
66
|
+
if (config) {
|
|
67
|
+
const reset = { ...config };
|
|
68
|
+
delete reset.keyboardShortcuts;
|
|
69
|
+
this.config.save(reset);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
17
72
|
}
|
package/dist/cli/commands/tui.js
CHANGED
|
@@ -31,7 +31,7 @@ export async function runInterface(options = {}) {
|
|
|
31
31
|
const updates = createUpdater();
|
|
32
32
|
// Light or dark palette, from the terminal's own background (SOJA_THEME overrides).
|
|
33
33
|
setThemeMode(await detectThemeMode());
|
|
34
|
-
const instance = render(_jsx(App, { services: runtime.services, welcomeOnLocal: savedConfig?.mode === 'remote' && !hasRemoteToken, updates: () => updates.cachedCheck(), mouse: process.env.SOJA_MOUSE !== '0' && runtime.services.preferences.mouse(), ...(options.route ? { initialRoute: options.route } : {}) }), { alternateScreen: true, exitOnCtrlC: true });
|
|
34
|
+
const instance = render(_jsx(App, { services: runtime.services, welcomeOnLocal: savedConfig?.mode === 'remote' && !hasRemoteToken, updates: { check: () => updates.cachedCheck(), install: (version) => updates.install(version) }, mouse: process.env.SOJA_MOUSE !== '0' && runtime.services.preferences.mouse(), ...(options.route ? { initialRoute: options.route } : {}) }), { alternateScreen: true, exitOnCtrlC: true });
|
|
35
35
|
await instance.waitUntilExit();
|
|
36
36
|
reopenInCurrentMode = new FileConfigStore(runtime.paths.configFile).load()?.mode !== runtime.services.environment.mode;
|
|
37
37
|
}
|
package/dist/config/config.js
CHANGED
|
@@ -30,6 +30,8 @@ export const configSchema = z
|
|
|
30
30
|
parentFolders: z.array(z.string().min(1)).default([]),
|
|
31
31
|
/** Whether the interface reports the mouse (`soja mouse on|off`, `M` in the interface). On when unset. */
|
|
32
32
|
mouse: z.boolean().optional(),
|
|
33
|
+
/** User-defined single-key global navigation shortcuts. */
|
|
34
|
+
keyboardShortcuts: z.record(z.string(), z.string()).optional(),
|
|
33
35
|
remote: remoteSchema.optional(),
|
|
34
36
|
})
|
|
35
37
|
.refine((config) => config.mode !== 'remote' || config.remote !== undefined, {
|
|
@@ -12,4 +12,7 @@ export class ReplicaFinanceService {
|
|
|
12
12
|
async setMySalary(session, input) {
|
|
13
13
|
await this.context.api.request('PUT', `/v1/workspaces/${session.workspace.id}/finance/salary`, input);
|
|
14
14
|
}
|
|
15
|
+
async configurePayroll(session, input) {
|
|
16
|
+
await this.context.api.request('PUT', `/v1/workspaces/${session.workspace.id}/finance/payroll`, input);
|
|
17
|
+
}
|
|
15
18
|
}
|
package/dist/ui/Shell.js
CHANGED
|
@@ -28,6 +28,9 @@ import { WorkspacesScreen } from './screens/WorkspacesScreen.js';
|
|
|
28
28
|
import { ChatScreen } from './screens/ChatScreen.js';
|
|
29
29
|
import { AccessScreen } from './screens/AccessScreen.js';
|
|
30
30
|
import { FinanceScreen } from './screens/FinanceScreen.js';
|
|
31
|
+
import { SettingsScreen } from './screens/SettingsScreen.js';
|
|
32
|
+
import { ProfileScreen } from './screens/ProfileScreen.js';
|
|
33
|
+
import { matchesGlobalShortcut } from '../application/services/preference-service.js';
|
|
31
34
|
import { ApiClient } from '../data/remote/api-client.js';
|
|
32
35
|
import { CredentialStore } from '../config/credentials.js';
|
|
33
36
|
import { resolvePaths } from '../config/paths.js';
|
|
@@ -77,31 +80,34 @@ function Frame() {
|
|
|
77
80
|
}, [followPullRequests]);
|
|
78
81
|
const mouseToggle = useMouseToggle();
|
|
79
82
|
useKeys(Layer.global, (input, key) => {
|
|
80
|
-
|
|
83
|
+
const shortcuts = services.preferences.keyboardShortcuts();
|
|
84
|
+
if (matchesGlobalShortcut(input, key, shortcuts.commands) || (key.ctrl && input === 'k'))
|
|
81
85
|
openPalette();
|
|
82
|
-
else if (key
|
|
83
|
-
return false;
|
|
84
|
-
else if (input === '/')
|
|
86
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.search))
|
|
85
87
|
openOverlay({ kind: 'search' });
|
|
86
|
-
else if (input
|
|
88
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.newTask))
|
|
87
89
|
openOverlay({ kind: 'new-task', projectId: newTaskProjectId });
|
|
88
|
-
else if (input
|
|
90
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.help))
|
|
89
91
|
go({ type: 'push', route: { name: 'help' } });
|
|
90
|
-
else if (input
|
|
92
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.projects))
|
|
91
93
|
go({ type: 'push', route: { name: 'projects' } });
|
|
92
|
-
else if (input
|
|
94
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.workspaces))
|
|
93
95
|
go({ type: 'push', route: { name: 'workspaces' } });
|
|
94
|
-
else if (input
|
|
96
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.finance) && services.finance)
|
|
95
97
|
go({ type: 'push', route: { name: 'finance' } });
|
|
96
|
-
else if (input
|
|
98
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.access))
|
|
97
99
|
go({ type: 'push', route: { name: 'access' } });
|
|
98
|
-
else if (input
|
|
100
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.mouse))
|
|
99
101
|
mouseToggle.toggle();
|
|
100
|
-
else if (input
|
|
102
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.chat) && chat)
|
|
101
103
|
go({ type: 'push', route: { name: 'chat' } });
|
|
104
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.settings))
|
|
105
|
+
go({ type: 'push', route: { name: 'settings' } });
|
|
106
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.profile))
|
|
107
|
+
go({ type: 'push', route: { name: 'profile' } });
|
|
102
108
|
else if (key.escape && stack.length > 1)
|
|
103
109
|
go({ type: 'pop' });
|
|
104
|
-
else if (input
|
|
110
|
+
else if (matchesGlobalShortcut(input, key, shortcuts.quit)) {
|
|
105
111
|
if (stack.length > 1)
|
|
106
112
|
go({ type: 'pop' });
|
|
107
113
|
else
|
|
@@ -115,7 +121,7 @@ function Frame() {
|
|
|
115
121
|
const { flash } = useAppState();
|
|
116
122
|
const server = services.environment.mode === 'remote' ? new URL(services.environment.server).host : undefined;
|
|
117
123
|
const syncLabel = syncLabelFor(syncStatus);
|
|
118
|
-
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(TopBar, { columns: columns, workspace: session.workspace.name, trail: trailFor(route, project.data), username: session.user.username, isCeo: accountBadge.data === true, server: server, sync: syncLabel, chat: route.name === 'chat' ? undefined : (chatTotals.data ?? undefined) }), _jsxs(Box, { flexGrow: 1, flexShrink: 1, children: [sidebar ? _jsx(Sidebar, { width: sidebar, height: rows - 1 - FOOTER_GAP_ROWS - footerRowsFor(columns), active: !overlay }) : null, _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: stack.map((entry, index) => {
|
|
124
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(TopBar, { columns: columns, workspace: session.workspace.name, trail: trailFor(route, project.data), username: session.user.username, isCeo: accountBadge.data === true, onProfileClick: () => go({ type: 'push', route: { name: 'profile' } }), profileActive: !overlay, server: server, sync: syncLabel, chat: route.name === 'chat' ? undefined : (chatTotals.data ?? undefined) }), _jsxs(Box, { flexGrow: 1, flexShrink: 1, children: [sidebar ? _jsx(Sidebar, { width: sidebar, height: rows - 1 - FOOTER_GAP_ROWS - footerRowsFor(columns), active: !overlay }) : null, _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: stack.map((entry, index) => {
|
|
119
125
|
const top = index === stack.length - 1;
|
|
120
126
|
// The screen under a window stays visible, but only the window takes keys and clicks.
|
|
121
127
|
const active = top && !overlay;
|
|
@@ -136,6 +142,10 @@ function ScreenFor({ route, active }) {
|
|
|
136
142
|
return _jsx(WorkspacesScreen, { active: active });
|
|
137
143
|
case 'finance':
|
|
138
144
|
return _jsx(FinanceScreen, { active: active });
|
|
145
|
+
case 'settings':
|
|
146
|
+
return _jsx(SettingsScreen, { active: active });
|
|
147
|
+
case 'profile':
|
|
148
|
+
return _jsx(ProfileScreen, { active: active });
|
|
139
149
|
case 'access':
|
|
140
150
|
return _jsx(AccessScreen, { active: active });
|
|
141
151
|
case 'help':
|
|
@@ -176,6 +186,10 @@ function trailFor(route, projectName) {
|
|
|
176
186
|
return ['Workspaces'];
|
|
177
187
|
case 'finance':
|
|
178
188
|
return ['Finance'];
|
|
189
|
+
case 'settings':
|
|
190
|
+
return ['Settings'];
|
|
191
|
+
case 'profile':
|
|
192
|
+
return ['Profile'];
|
|
179
193
|
case 'access':
|
|
180
194
|
return ['Access approvals'];
|
|
181
195
|
case 'help':
|
package/dist/ui/app-state.js
CHANGED
|
@@ -39,14 +39,32 @@ export function AppStateProvider({ services, cwd, initialSession, initialRoute,
|
|
|
39
39
|
if (!updates)
|
|
40
40
|
return;
|
|
41
41
|
let active = true;
|
|
42
|
-
void updates().then((update) => {
|
|
43
|
-
if (active && update?.available)
|
|
44
|
-
|
|
42
|
+
void updates.check().then((update) => {
|
|
43
|
+
if (active && update?.available) {
|
|
44
|
+
setOverlay({
|
|
45
|
+
kind: 'confirm',
|
|
46
|
+
title: `SOJA v${update.latest} is available`,
|
|
47
|
+
context: `Current version: v${update.current}`,
|
|
48
|
+
message: 'Install the update from npm now? SOJA will close after installation so you can start the new version.',
|
|
49
|
+
confirmLabel: 'Update now',
|
|
50
|
+
onConfirm: async () => {
|
|
51
|
+
try {
|
|
52
|
+
await updates.install(update.latest);
|
|
53
|
+
exit();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const display = toDisplayError(error);
|
|
57
|
+
notify(display.message, 'error', display.hint);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
45
63
|
});
|
|
46
64
|
return () => {
|
|
47
65
|
active = false;
|
|
48
66
|
};
|
|
49
|
-
}, [updates, notify]);
|
|
67
|
+
}, [updates, notify, exit]);
|
|
50
68
|
const run = useCallback(async (action, success) => {
|
|
51
69
|
try {
|
|
52
70
|
await action();
|
package/dist/ui/chrome/TopBar.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { APP_NAME, APP_VERSION } from '../branding/brand.js';
|
|
4
|
+
import { Clickable } from '../kit/Clickable.js';
|
|
4
5
|
import { palette, symbols } from '../theme/theme.js';
|
|
5
6
|
/** The title bar: brand and place on the left, connection and you on the right. */
|
|
6
|
-
export function TopBar({ columns, workspace, trail, username, isCeo, server, sync, chat }) {
|
|
7
|
+
export function TopBar({ columns, workspace, trail, username, isCeo, server, sync, chat, onProfileClick, profileActive = true }) {
|
|
7
8
|
const roomy = columns >= 90;
|
|
8
9
|
const syncColor = sync?.tone === 'warn' ? palette.warning : sync?.tone === 'busy' ? palette.info : palette.muted;
|
|
9
|
-
return (_jsxs(Box, { width: columns, backgroundColor: palette.bar, justifyContent: "space-between", flexShrink: 0, children: [_jsx(Box, { flexShrink: 1, children: _jsxs(Text, { wrap: "truncate-end", backgroundColor: palette.bar, children: [_jsx(Text, { backgroundColor: palette.accent, color: palette.onAccent, bold: true, children: ` ${APP_NAME}${symbols.cursor} ` }), _jsx(Text, { color: palette.barText, bold: true, children: ` ${workspace}` }), trail.map((part) => (_jsx(Text, { color: palette.muted, children: ` ${symbols.chevron} ${part}` }, part)))] }) }), _jsxs(Box, { flexShrink: 0, gap: 2, paddingRight: 1, children: [chat && chat.unread > 0 ? (_jsxs(Text, { backgroundColor: palette.bar, children: [_jsx(Text, { color: palette.accent, children: `${symbols.unread} ${chat.unread}` }), chat.mentions > 0 ? _jsx(Text, { color: palette.warning, bold: true, children: ` @${chat.mentions}` }) : null] })) : null, server ? (_jsx(Text, { backgroundColor: palette.bar, color: syncColor, children: `${symbols.link} ${roomy ? server : ''}${sync ? `${roomy ? ` ${symbols.dot} ` : ''}${sync.text}` : ''}` })) : null, _jsxs(Text, { backgroundColor: palette.bar, color: palette.barText, children: [`@${username}`, isCeo ? _jsx(Text, { color: palette.warning, bold: true, children: ' ★ CEO' }) : null] }), roomy ? _jsx(Text, { backgroundColor: palette.bar, color: palette.faint, children: `v${APP_VERSION}` }) : null] })] }));
|
|
10
|
+
return (_jsxs(Box, { width: columns, backgroundColor: palette.bar, justifyContent: "space-between", flexShrink: 0, children: [_jsx(Box, { flexShrink: 1, children: _jsxs(Text, { wrap: "truncate-end", backgroundColor: palette.bar, children: [_jsx(Text, { backgroundColor: palette.accent, color: palette.onAccent, bold: true, children: ` ${APP_NAME}${symbols.cursor} ` }), _jsx(Text, { color: palette.barText, bold: true, children: ` ${workspace}` }), trail.map((part) => (_jsx(Text, { color: palette.muted, children: ` ${symbols.chevron} ${part}` }, part)))] }) }), _jsxs(Box, { flexShrink: 0, gap: 2, paddingRight: 1, children: [chat && chat.unread > 0 ? (_jsxs(Text, { backgroundColor: palette.bar, children: [_jsx(Text, { color: palette.accent, children: `${symbols.unread} ${chat.unread}` }), chat.mentions > 0 ? _jsx(Text, { color: palette.warning, bold: true, children: ` @${chat.mentions}` }) : null] })) : null, server ? (_jsx(Text, { backgroundColor: palette.bar, color: syncColor, children: `${symbols.link} ${roomy ? server : ''}${sync ? `${roomy ? ` ${symbols.dot} ` : ''}${sync.text}` : ''}` })) : null, _jsx(Clickable, { onClick: onProfileClick ? () => onProfileClick() : undefined, active: profileActive, children: _jsxs(Text, { backgroundColor: palette.bar, color: palette.barText, children: [`@${username}`, isCeo ? _jsx(Text, { color: palette.warning, bold: true, children: ' ★ CEO' }) : null] }) }), roomy ? _jsx(Text, { backgroundColor: palette.bar, color: palette.faint, children: `v${APP_VERSION}` }) : null] })] }));
|
|
10
11
|
}
|
|
@@ -13,26 +13,30 @@ export function useCommandPalette(newTaskProjectId) {
|
|
|
13
13
|
const flows = useFlows();
|
|
14
14
|
const mouse = useMouseToggle();
|
|
15
15
|
return () => {
|
|
16
|
+
const shortcuts = services.preferences.keyboardShortcuts();
|
|
16
17
|
const commands = [
|
|
17
|
-
{ id: 'new-task', label: 'New task', key:
|
|
18
|
+
{ id: 'new-task', label: 'New task', key: shortcuts.newTask, run: () => openOverlay({ kind: 'new-task', projectId: newTaskProjectId }) },
|
|
18
19
|
...TASK_FILTERS.map((filter) => ({
|
|
19
20
|
id: `filter-${filter}`,
|
|
20
21
|
label: FILTER_LABELS[filter],
|
|
21
22
|
run: () => showView(filter),
|
|
22
23
|
})),
|
|
23
24
|
{ id: 'filter-archived', label: 'Archived tasks', run: () => showView('archived') },
|
|
24
|
-
{ id: 'search', label: 'Search', key:
|
|
25
|
-
{ id: 'projects', label: 'Projects', key:
|
|
25
|
+
{ id: 'search', label: 'Search', key: shortcuts.search, run: () => openOverlay({ kind: 'search' }) },
|
|
26
|
+
{ id: 'projects', label: 'Projects', key: shortcuts.projects, run: () => go({ type: 'push', route: { name: 'projects' } }) },
|
|
26
27
|
{ id: 'switch-project', label: 'Switch project', run: () => flows.pickProject() },
|
|
27
28
|
{ id: 'new-project', label: 'New project', run: () => flows.newProject() },
|
|
28
29
|
{ id: 'switch-workspace', label: 'Switch workspace', run: () => flows.pickWorkspace() },
|
|
29
|
-
{ id: 'workspaces', label: 'Workspaces', key:
|
|
30
|
-
...(services.finance ? [{ id: 'finance', label: 'Finance & performance', key:
|
|
30
|
+
{ id: 'workspaces', label: 'Workspaces', key: shortcuts.workspaces, run: () => go({ type: 'push', route: { name: 'workspaces' } }) },
|
|
31
|
+
...(services.finance ? [{ id: 'finance', label: 'Finance & performance', key: shortcuts.finance, run: () => go({ type: 'push', route: { name: 'finance' } }) }] : []),
|
|
32
|
+
{ id: 'settings', label: 'Settings', key: shortcuts.settings, run: () => go({ type: 'push', route: { name: 'settings' } }) },
|
|
33
|
+
{ id: 'profile', label: 'My profile', key: shortcuts.profile, run: () => go({ type: 'push', route: { name: 'profile' } }) },
|
|
34
|
+
{ id: 'access', label: 'Access approvals', key: shortcuts.access, run: () => go({ type: 'push', route: { name: 'access' } }) },
|
|
31
35
|
{ id: 'new-workspace', label: 'New workspace', run: () => flows.newWorkspace() },
|
|
32
36
|
{ id: 'add-developer', label: 'Add developer', run: () => flows.addDeveloper() },
|
|
33
37
|
{ id: 'parent-folders', label: 'Parent folders', run: () => flows.manageParentFolders() },
|
|
34
38
|
{ id: 'git-log', label: 'Git log', run: () => openOverlay({ kind: 'git-log' }) },
|
|
35
|
-
...(services.chat ? [{ id: 'chat', label: 'Chat', key:
|
|
39
|
+
...(services.chat ? [{ id: 'chat', label: 'Chat', key: shortcuts.chat, run: () => go({ type: 'push', route: { name: 'chat' } }) }] : []),
|
|
36
40
|
...(services.sync
|
|
37
41
|
? [{
|
|
38
42
|
id: 'sync',
|
|
@@ -43,9 +47,9 @@ export function useCommandPalette(newTaskProjectId) {
|
|
|
43
47
|
},
|
|
44
48
|
}]
|
|
45
49
|
: []),
|
|
46
|
-
{ id: 'mouse', label: mouse.enabled ? 'Mouse off' : 'Mouse on', key:
|
|
47
|
-
{ id: 'help', label: 'Help', key:
|
|
48
|
-
{ id: 'quit', label: 'Quit', key:
|
|
50
|
+
{ id: 'mouse', label: mouse.enabled ? 'Mouse off' : 'Mouse on', key: shortcuts.mouse, run: mouse.toggle },
|
|
51
|
+
{ id: 'help', label: 'Help', key: shortcuts.help, run: () => go({ type: 'push', route: { name: 'help' } }) },
|
|
52
|
+
{ id: 'quit', label: 'Quit', key: shortcuts.quit, run: quit },
|
|
49
53
|
];
|
|
50
54
|
const spec = {
|
|
51
55
|
kind: 'picker',
|
|
@@ -18,6 +18,7 @@ export function FinanceScreen({ active }) {
|
|
|
18
18
|
const query = useQuery(() => finance ? finance.summary(session) : Promise.reject(new Error('Finance is only available in online workspaces.')), topic, [topic]);
|
|
19
19
|
const data = query.data;
|
|
20
20
|
const enoughMembers = (data?.performanceByMember.length ?? 0) >= 2;
|
|
21
|
+
const ticketHistory = data?.myTicketHistory ?? [];
|
|
21
22
|
useKeys(Layer.screen, (input, key) => {
|
|
22
23
|
if (!finance || key.ctrl || key.meta)
|
|
23
24
|
return false;
|
|
@@ -57,6 +58,29 @@ export function FinanceScreen({ active }) {
|
|
|
57
58
|
} });
|
|
58
59
|
return true;
|
|
59
60
|
}
|
|
61
|
+
if (input === 'p') {
|
|
62
|
+
openOverlay({ kind: 'picker', title: 'Payroll schedule', initial: data?.myPayroll?.schedule ?? null, options: [
|
|
63
|
+
{ value: 'monthly', label: 'Once a month · choose a day' },
|
|
64
|
+
{ value: 'semimonthly', label: '15th and last day · split salary equally' },
|
|
65
|
+
], onSelect: (schedule) => {
|
|
66
|
+
if (schedule === 'semimonthly')
|
|
67
|
+
return run(async () => {
|
|
68
|
+
await finance.configurePayroll(session, { schedule, timeZone: localTimeZone() });
|
|
69
|
+
invalidateQueries([topic]);
|
|
70
|
+
}, 'Payroll set to the 15th and last day of the month');
|
|
71
|
+
openOverlay({ kind: 'prompt', title: 'Monthly payday (1–31)', initial: String(data?.myPayroll?.dayOfMonth ?? 31), placeholder: '31 means the last day of every month', onSubmit: (value) => {
|
|
72
|
+
const dayOfMonth = Number(value.trim());
|
|
73
|
+
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31)
|
|
74
|
+
return false;
|
|
75
|
+
return run(async () => {
|
|
76
|
+
await finance.configurePayroll(session, { schedule: 'monthly', dayOfMonth, timeZone: localTimeZone() });
|
|
77
|
+
invalidateQueries([topic]);
|
|
78
|
+
}, `Monthly payday set to day ${dayOfMonth}`);
|
|
79
|
+
} });
|
|
80
|
+
return true;
|
|
81
|
+
} });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
60
84
|
return false;
|
|
61
85
|
}, active);
|
|
62
86
|
if (!finance)
|
|
@@ -64,13 +88,13 @@ export function FinanceScreen({ active }) {
|
|
|
64
88
|
if (query.error && !data)
|
|
65
89
|
return _jsx(ScreenFrame, { title: "Finance", hints: [['esc', 'back']], children: _jsx(EmptyState, { lines: [query.error.message, query.error.hint ?? ''], icon: symbols.cross }) });
|
|
66
90
|
if (!data)
|
|
67
|
-
return _jsx(ScreenFrame, { title: "Finance", hints: [['c', 'currency'], ['s', 'salary'], ['esc', 'back']], children: "Loading finance\u2026" });
|
|
91
|
+
return _jsx(ScreenFrame, { title: "Finance", hints: [['c', 'currency'], ['s', 'salary'], ['p', 'payday'], ['esc', 'back']], children: _jsx(Text, { color: palette.faint, children: "Loading finance\u2026" }) });
|
|
68
92
|
const compact = height < 25 || width < 75;
|
|
69
93
|
const rows = Math.max(1, Math.min(6, height - (compact ? 8 : 12)));
|
|
70
94
|
const financeMembers = data.paidByMember.slice(0, rows);
|
|
71
95
|
const currencyCode = data.currencyCode;
|
|
72
96
|
const performance = data.performanceByMember.slice(0, rows);
|
|
73
|
-
return (_jsx(ScreenFrame, { title: "Finance & performance", aside: session.workspace.name, hints: [['c', 'currency'], ['s', 'my salary'], ['esc', 'back']], children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Panel, { title: "My base salary", aside: "private to you", height:
|
|
97
|
+
return (_jsx(ScreenFrame, { title: "Finance & performance", aside: session.workspace.name, hints: [['c', 'currency'], ['s', 'my salary'], ['p', 'payday'], ['esc', 'back']], children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Panel, { title: "My base salary", aside: "private to you", height: 5, children: [_jsx(Text, { color: palette.text, children: data.mySalary && data.currencyCode ? `${money(data.mySalary.amountMinor, data.currencyCode)} / ${data.mySalary.period}` : 'Not set · press s' }), _jsxs(Text, { color: palette.text, children: ["Payroll \u00B7 ", data.myPayroll ? `${payrollLabel(data.myPayroll)} · next ${formatDate(data.myPayroll.nextPayday)}` : 'not set · press p'] }), _jsx(Text, { color: palette.faint, children: "Excluded from ticket earnings and workspace comparisons." })] }), data.myPayroll && currencyCode ? (_jsxs(Panel, { title: `Upcoming payroll · ${formatDate(data.myPayroll.nextPayday)}`, height: 4, children: [_jsx(Text, { color: palette.text, children: `Base salary${data.myPayroll.salaryAmountMinor === null ? ' (not monthly)' : ''} · ${data.myPayroll.salaryAmountMinor === null ? 'not estimated' : money(data.myPayroll.salaryAmountMinor, currencyCode)}` }), _jsx(Text, { color: palette.accent, children: `Ticket income · ${money(data.myPayroll.ticketEarningsMinor, currencyCode)} | Estimated total · ${data.myPayroll.salaryAmountMinor === null ? money(data.myPayroll.ticketEarningsMinor, currencyCode) : money(data.myPayroll.totalAmountMinor, currencyCode)}` })] })) : null, !data.currencyCode ? (_jsxs(Panel, { title: "Workspace currency", height: 5, children: [_jsx(Text, { color: palette.warning, children: "Not configured \u00B7 press c to choose an ISO 4217 currency." }), _jsx(Text, { color: palette.faint, children: "Ticket prices and team finance totals use one currency." })] })) : null, enoughMembers ? (_jsxs(Box, { flexDirection: compact ? 'column' : 'row', gap: 1, children: [_jsxs(Panel, { title: `Ticket earnings · ${data.currencyCode ?? '—'}`, flexGrow: 1, children: [currencyCode ? financeMembers.map((member) => _jsx(ChartRow, { label: `@${member.username}`, value: member.amountMinor, max: Math.max(1, ...financeMembers.map((row) => row.amountMinor)), text: money(member.amountMinor, currencyCode) }, member.id)) : _jsx(Text, { color: palette.faint, children: "Set the workspace currency to enable paid tickets." }), data.currencyCode && financeMembers.length === 0 ? _jsx(Text, { color: palette.faint, children: "No ticket earnings yet." }) : null, _jsx(Text, { color: palette.faint, children: "Only remunerated tickets moved to Done. Salary is excluded." })] }), _jsxs(Panel, { title: "Performance \u00B7 all ticket types", flexGrow: 1, children: [performance.map((member) => _jsx(ChartRow, { label: `@${member.username}`, value: member.ticketCount, max: Math.max(1, ...performance.map((row) => row.ticketCount)), text: `${member.ticketCount} tickets · ${member.openCount} open` }, member.id)), _jsxs(Text, { color: palette.faint, children: ["Average close time \u00B7 ", performance.map((member) => `${member.username}: ${duration(member.averageCloseMs)}`).join(' · ')] }), _jsx(Text, { color: palette.faint, children: "Open age counts every ticket; cancelled tickets are excluded from close averages." })] })] })) : (_jsx(Panel, { title: "Workspace comparisons", height: 4, children: _jsx(Text, { color: palette.faint, children: "Comparisons appear when at least two workspace members are eligible." }) })), _jsx(Panel, { title: "My ticket income history", aside: "positive income in green", height: Math.max(4, Math.min(ticketHistory.length + 3, rows + 3)), children: ticketHistory.length === 0 ? _jsx(Text, { color: palette.faint, children: "No paid ticket history yet." }) : ticketHistory.slice(0, rows).map((entry) => (_jsxs(Box, { justifyContent: "space-between", gap: 1, children: [_jsx(Text, { color: palette.text, wrap: "truncate-end", children: `${entry.taskRef} ${entry.taskTitle}` }), _jsx(Text, { color: entry.amountMinor > 0 ? palette.accent : entry.amountMinor < 0 ? palette.danger : palette.muted, bold: true, children: money(entry.amountMinor, entry.currencyCode) }), _jsx(Text, { color: palette.faint, children: `${entry.eventType} · ${formatDate(entry.createdAt)} → payroll ${formatDate(entry.payrollDate)}` })] }, entry.id))) }), _jsx(Text, { color: palette.faint, children: "Values are accrued in SOJA when approved and marked Done; they do not confirm a bank transfer." })] }) }));
|
|
74
98
|
}
|
|
75
99
|
function ChartRow({ label, value, max, text }) {
|
|
76
100
|
const bar = Math.max(value > 0 ? 1 : 0, Math.round((value / max) * 16));
|
|
@@ -86,3 +110,13 @@ function duration(milliseconds) {
|
|
|
86
110
|
const hours = milliseconds / 3_600_000;
|
|
87
111
|
return hours < 48 ? `${hours.toFixed(1)}h` : `${(hours / 24).toFixed(1)}d`;
|
|
88
112
|
}
|
|
113
|
+
function payrollLabel(payroll) {
|
|
114
|
+
return payroll.schedule === 'semimonthly' ? '15th and last day' : `monthly on day ${payroll.dayOfMonth}`;
|
|
115
|
+
}
|
|
116
|
+
function formatDate(value) {
|
|
117
|
+
const date = value instanceof Date ? value : new Date(`${value.slice(0, 10)}T12:00:00`);
|
|
118
|
+
return Number.isNaN(date.valueOf()) ? String(value) : new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
|
|
119
|
+
}
|
|
120
|
+
function localTimeZone() {
|
|
121
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
122
|
+
}
|
|
@@ -70,6 +70,8 @@ const SECTIONS = [
|
|
|
70
70
|
['p', 'Projects'],
|
|
71
71
|
['w', 'Workspaces'],
|
|
72
72
|
['f', 'Finance & performance (online)'],
|
|
73
|
+
[',', 'Settings and global keyboard shortcuts'],
|
|
74
|
+
['u', 'My profile (online); username in the top bar is clickable'],
|
|
73
75
|
['A', 'Access approvals (CEO, remote mode)'],
|
|
74
76
|
['r', 'Link repo (in Projects)'],
|
|
75
77
|
],
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { CredentialStore } from '../../config/credentials.js';
|
|
5
|
+
import { resolvePaths } from '../../config/paths.js';
|
|
6
|
+
import { ApiClient } from '../../data/remote/api-client.js';
|
|
7
|
+
import { useAppState } from '../app-state.js';
|
|
8
|
+
import { EmptyState } from '../components/EmptyState.js';
|
|
9
|
+
import { useQuery } from '../hooks/use-query.js';
|
|
10
|
+
import { Layer } from '../input/dispatcher.js';
|
|
11
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
12
|
+
import { Panel } from '../kit/Panel.js';
|
|
13
|
+
import { ScreenFrame } from './ScreenFrame.js';
|
|
14
|
+
import { palette, symbols } from '../theme/theme.js';
|
|
15
|
+
import { toDisplayError } from '../../utils/errors.js';
|
|
16
|
+
export function ProfileScreen({ active }) {
|
|
17
|
+
const { services, session, openOverlay, run, invalidateQueries, notify } = useAppState();
|
|
18
|
+
const client = useMemo(() => {
|
|
19
|
+
if (services.environment.mode !== 'remote')
|
|
20
|
+
return null;
|
|
21
|
+
const token = new CredentialStore(resolvePaths().credentialsFile).token(services.environment.server);
|
|
22
|
+
return token ? new ApiClient(services.environment.server, token) : null;
|
|
23
|
+
}, [services.environment]);
|
|
24
|
+
const topic = `profile:${session.user.id}`;
|
|
25
|
+
const query = useQuery(() => client
|
|
26
|
+
? client.get('/v1/profile').then((result) => result.user)
|
|
27
|
+
: Promise.reject(new Error('Your SOJA profile is available while signed in online.')), topic, [topic]);
|
|
28
|
+
const profile = query.data;
|
|
29
|
+
const save = (changes) => {
|
|
30
|
+
if (!client || !profile)
|
|
31
|
+
return false;
|
|
32
|
+
if (!profile.dateOfBirth || !profile.countryCode) {
|
|
33
|
+
openOverlay({ kind: 'prompt', title: 'Complete profile · date of birth (YYYY-MM-DD)', initial: profile.dateOfBirth ?? '', onSubmit: (dateOfBirth) => {
|
|
34
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOfBirth))
|
|
35
|
+
return false;
|
|
36
|
+
return run(async () => {
|
|
37
|
+
await client.request('PUT', '/v1/profile', { displayName: profile.displayName, dateOfBirth, countryCode: profile.countryCode ?? '' });
|
|
38
|
+
invalidateQueries([topic]);
|
|
39
|
+
}, 'Profile updated');
|
|
40
|
+
} });
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return run(async () => {
|
|
44
|
+
await client.request('PUT', '/v1/profile', { displayName: changes.displayName ?? profile.displayName, dateOfBirth: changes.dateOfBirth ?? profile.dateOfBirth, countryCode: changes.countryCode ?? profile.countryCode });
|
|
45
|
+
invalidateQueries([topic]);
|
|
46
|
+
}, 'Profile updated');
|
|
47
|
+
};
|
|
48
|
+
useKeys(Layer.screen, (input) => {
|
|
49
|
+
if (!profile || !client)
|
|
50
|
+
return false;
|
|
51
|
+
if (input === 'n') {
|
|
52
|
+
openOverlay({ kind: 'prompt', title: 'Display name', initial: profile.displayName, placeholder: 'Your name', onSubmit: (displayName) => {
|
|
53
|
+
const cleaned = displayName.trim();
|
|
54
|
+
if (!cleaned || cleaned.length > 80)
|
|
55
|
+
return false;
|
|
56
|
+
return save({ displayName: cleaned });
|
|
57
|
+
} });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (input === 'd') {
|
|
61
|
+
openOverlay({ kind: 'prompt', title: 'Date of birth (YYYY-MM-DD)', initial: profile.dateOfBirth ?? '', placeholder: '1990-01-31', onSubmit: (dateOfBirth) => save({ dateOfBirth }) });
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (input === 'c') {
|
|
65
|
+
void client.get('/v1/auth/countries').then(({ countries }) => {
|
|
66
|
+
openOverlay({ kind: 'picker', title: 'Country', initial: profile.countryCode, filterable: true, options: countries.map((country) => ({ value: country.code, label: `${country.flag} ${country.name} · ${country.code}` })), onSelect: (countryCode) => save({ countryCode }) });
|
|
67
|
+
}).catch((error) => notify(toDisplayError(error).message, 'error'));
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}, active);
|
|
72
|
+
if (services.environment.mode !== 'remote')
|
|
73
|
+
return _jsx(ScreenFrame, { title: "Profile", hints: [['esc', 'back']], children: _jsx(EmptyState, { lines: ['Sign in to view your online profile.'], icon: symbols.dot }) });
|
|
74
|
+
if (query.error && !profile)
|
|
75
|
+
return _jsx(ScreenFrame, { title: "Profile", hints: [['esc', 'back']], children: _jsx(EmptyState, { lines: [query.error.message, query.error.hint ?? ''], icon: symbols.cross }) });
|
|
76
|
+
if (!profile)
|
|
77
|
+
return _jsx(ScreenFrame, { title: "Profile", hints: [['esc', 'back']], children: _jsx(Text, { color: palette.faint, children: "Loading profile\u2026" }) });
|
|
78
|
+
return (_jsx(ScreenFrame, { title: "My profile", aside: `@${profile.username}`, hints: [['n', 'name'], ['d', 'birth date'], ['c', 'country'], ['esc', 'back']], children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Panel, { title: "SOJA account", height: 7, children: [_jsxs(Text, { color: palette.text, children: ["Username \u00B7 @", profile.username] }), _jsxs(Text, { color: palette.text, children: ["Display name \u00B7 ", profile.displayName] }), _jsxs(Text, { color: palette.text, children: ["Country \u00B7 ", profile.countryCode ?? 'not set'] }), _jsxs(Text, { color: palette.text, children: ["Date of birth \u00B7 ", profile.dateOfBirth ?? 'not set'] }), _jsxs(Text, { color: palette.faint, children: ["Access \u00B7 ", profile.accessStatus, profile.isCeo ? ' · ★ CEO' : ''] })] }), _jsx(Text, { color: palette.faint, children: "Personal profile details are visible only to you here. Change them with n, d or c." })] }) }));
|
|
79
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { normalizeGlobalShortcut } from '../../application/services/preference-service.js';
|
|
5
|
+
import { useAppState } from '../app-state.js';
|
|
6
|
+
import { useLayout } from '../hooks/use-layout.js';
|
|
7
|
+
import { Layer } from '../input/dispatcher.js';
|
|
8
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
9
|
+
import { Panel } from '../kit/Panel.js';
|
|
10
|
+
import { ScreenFrame } from './ScreenFrame.js';
|
|
11
|
+
import { palette } from '../theme/theme.js';
|
|
12
|
+
const ACTIONS = [
|
|
13
|
+
{ id: 'commands', label: 'Command palette' },
|
|
14
|
+
{ id: 'search', label: 'Search' },
|
|
15
|
+
{ id: 'newTask', label: 'New task' },
|
|
16
|
+
{ id: 'projects', label: 'Projects' },
|
|
17
|
+
{ id: 'workspaces', label: 'Workspaces' },
|
|
18
|
+
{ id: 'finance', label: 'Finance' },
|
|
19
|
+
{ id: 'access', label: 'Access requests' },
|
|
20
|
+
{ id: 'chat', label: 'Chat' },
|
|
21
|
+
{ id: 'mouse', label: 'Toggle mouse' },
|
|
22
|
+
{ id: 'help', label: 'Help' },
|
|
23
|
+
{ id: 'settings', label: 'Settings' },
|
|
24
|
+
{ id: 'profile', label: 'My profile' },
|
|
25
|
+
{ id: 'quit', label: 'Quit / back' },
|
|
26
|
+
];
|
|
27
|
+
export function SettingsScreen({ active }) {
|
|
28
|
+
const { services, openOverlay, run, notify } = useAppState();
|
|
29
|
+
const { height } = useLayout();
|
|
30
|
+
const shortcuts = services.preferences.keyboardShortcuts();
|
|
31
|
+
const [selected, setSelected] = useState(0);
|
|
32
|
+
useKeys(Layer.screen, (input, key) => {
|
|
33
|
+
if (key.upArrow) {
|
|
34
|
+
setSelected((index) => Math.max(0, index - 1));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (key.downArrow) {
|
|
38
|
+
setSelected((index) => Math.min(ACTIONS.length - 1, index + 1));
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (key.ctrl && input.toLowerCase() === 'r') {
|
|
42
|
+
void run(async () => services.preferences.resetKeyboardShortcuts(), 'Default shortcuts restored');
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (key.return) {
|
|
46
|
+
const action = ACTIONS[selected];
|
|
47
|
+
if (!action)
|
|
48
|
+
return false;
|
|
49
|
+
const current = services.preferences.keyboardShortcuts();
|
|
50
|
+
openOverlay({
|
|
51
|
+
kind: 'prompt',
|
|
52
|
+
title: `Shortcut for ${action.label}`,
|
|
53
|
+
initial: current[action.id],
|
|
54
|
+
placeholder: 'One key or Ctrl+letter',
|
|
55
|
+
onSubmit: (value) => {
|
|
56
|
+
const normalized = normalizeGlobalShortcut(value);
|
|
57
|
+
if (!normalized) {
|
|
58
|
+
notify('Use one printable key or Ctrl+letter. Ctrl+R is reserved for reset.', 'error');
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const conflict = ACTIONS.find((candidate) => candidate.id !== action.id && normalizeGlobalShortcut(current[candidate.id]) === normalized);
|
|
62
|
+
if (conflict) {
|
|
63
|
+
notify(`That key is already assigned to ${conflict.label}.`, 'error');
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return run(async () => services.preferences.setKeyboardShortcut(action.id, normalized), `${action.label} shortcut saved`);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}, active);
|
|
73
|
+
return (_jsx(ScreenFrame, { title: "Settings", hints: [['↑↓', 'select'], ['enter', 'change shortcut'], ['ctrl+r', 'reset'], ['esc', 'back']], children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Panel, { title: "Keyboard shortcuts \u00B7 global navigation", height: Math.max(3, Math.min(ACTIONS.length + 3, height - 2)), children: ACTIONS.map((action, index) => {
|
|
74
|
+
const chosen = selected === index;
|
|
75
|
+
return (_jsxs(Box, { justifyContent: "space-between", backgroundColor: chosen ? palette.selection : undefined, children: [_jsx(Text, { color: chosen ? palette.accent : palette.text, children: `${chosen ? '▌ ' : ' '}${action.label}` }), _jsx(Text, { color: palette.warning, bold: true, children: shortcuts[action.id] })] }, action.id));
|
|
76
|
+
}) }), _jsx(Text, { color: palette.faint, children: "Changes are saved on this machine. Enter a key or Ctrl+letter; screen-specific task and chat shortcuts stay unchanged." }), _jsxs(Text, { color: palette.faint, children: ["Profile: use ", shortcuts.profile, " or select your username in the top bar."] })] }) }));
|
|
77
|
+
}
|