soja-cli 1.6.3 → 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.
@@ -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
  }
@@ -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
  }
@@ -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
- if (input === ':' || (key.ctrl && input === 'k'))
83
+ const shortcuts = services.preferences.keyboardShortcuts();
84
+ if (matchesGlobalShortcut(input, key, shortcuts.commands) || (key.ctrl && input === 'k'))
81
85
  openPalette();
82
- else if (key.ctrl || key.meta)
83
- return false;
84
- else if (input === '/')
86
+ else if (matchesGlobalShortcut(input, key, shortcuts.search))
85
87
  openOverlay({ kind: 'search' });
86
- else if (input === 'n')
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 === 'p')
92
+ else if (matchesGlobalShortcut(input, key, shortcuts.projects))
91
93
  go({ type: 'push', route: { name: 'projects' } });
92
- else if (input === 'w')
94
+ else if (matchesGlobalShortcut(input, key, shortcuts.workspaces))
93
95
  go({ type: 'push', route: { name: 'workspaces' } });
94
- else if (input === 'f' && services.finance)
96
+ else if (matchesGlobalShortcut(input, key, shortcuts.finance) && services.finance)
95
97
  go({ type: 'push', route: { name: 'finance' } });
96
- else if (input === 'A')
98
+ else if (matchesGlobalShortcut(input, key, shortcuts.access))
97
99
  go({ type: 'push', route: { name: 'access' } });
98
- else if (input === 'M')
100
+ else if (matchesGlobalShortcut(input, key, shortcuts.mouse))
99
101
  mouseToggle.toggle();
100
- else if (input === '#' && chat)
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 === 'q') {
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':
@@ -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
- notify(`SOJA v${update.latest} is available`, 'info', 'Run `soja update` to install it.');
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();
@@ -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: 'n', run: () => openOverlay({ kind: 'new-task', projectId: newTaskProjectId }) },
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: '/', run: () => openOverlay({ kind: 'search' }) },
25
- { id: 'projects', label: 'Projects', key: 'p', run: () => go({ type: 'push', route: { name: 'projects' } }) },
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: 'w', run: () => go({ type: 'push', route: { name: 'workspaces' } }) },
30
- ...(services.finance ? [{ id: 'finance', label: 'Finance & performance', key: 'f', run: () => go({ type: 'push', route: { name: 'finance' } }) }] : []),
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: '#', run: () => go({ type: 'push', route: { name: 'chat' } }) }] : []),
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: 'M', run: mouse.toggle },
47
- { id: 'help', label: 'Help', key: '?', run: () => go({ type: 'push', route: { name: 'help' } }) },
48
- { id: 'quit', label: 'Quit', key: 'q', run: quit },
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: _jsx(Text, { color: palette.faint, 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: 4, children: [_jsx(Text, { color: palette.text, children: data.mySalary && data.currencyCode ? `${money(data.mySalary.amountMinor, data.currencyCode)} / ${data.mySalary.period}` : 'Not set · press s' }), _jsx(Text, { color: palette.faint, children: "Excluded from ticket earnings and workspace comparisons." })] }), !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(Text, { color: palette.faint, children: "Values are accrued in SOJA when approved and marked Done; they do not confirm a bank transfer." })] }) }));
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soja-cli",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "description": "Software Operations & Job Assistant",
5
5
  "author": "Enmauel.biz",
6
6
  "license": "SEE LICENSE IN LICENSE",