soja-cli 1.4.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.
Files changed (189) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +117 -0
  3. package/dist/application/chat.js +1 -0
  4. package/dist/application/filters.js +27 -0
  5. package/dist/application/import.js +1 -0
  6. package/dist/application/ports.js +1 -0
  7. package/dist/application/repository-root.js +18 -0
  8. package/dist/application/services/backup-service.js +93 -0
  9. package/dist/application/services/folder-service.js +116 -0
  10. package/dist/application/services/git-workflow-service.js +593 -0
  11. package/dist/application/services/index.js +24 -0
  12. package/dist/application/services/project-service.js +129 -0
  13. package/dist/application/services/session-service.js +52 -0
  14. package/dist/application/services/task-service.js +350 -0
  15. package/dist/application/services/update-service.js +60 -0
  16. package/dist/application/services/workspace-service.js +81 -0
  17. package/dist/application/timeline.js +83 -0
  18. package/dist/application/types.js +1 -0
  19. package/dist/application/validation.js +30 -0
  20. package/dist/bootstrap.js +91 -0
  21. package/dist/cli/commands/access.js +60 -0
  22. package/dist/cli/commands/account.js +203 -0
  23. package/dist/cli/commands/args.js +27 -0
  24. package/dist/cli/commands/backup.js +70 -0
  25. package/dist/cli/commands/chat.js +133 -0
  26. package/dist/cli/commands/dev.js +59 -0
  27. package/dist/cli/commands/folders.js +59 -0
  28. package/dist/cli/commands/git-ops.js +200 -0
  29. package/dist/cli/commands/import.js +48 -0
  30. package/dist/cli/commands/info.js +82 -0
  31. package/dist/cli/commands/project.js +108 -0
  32. package/dist/cli/commands/start.js +34 -0
  33. package/dist/cli/commands/sync.js +43 -0
  34. package/dist/cli/commands/task.js +309 -0
  35. package/dist/cli/commands/tui.js +43 -0
  36. package/dist/cli/commands/update.js +20 -0
  37. package/dist/cli/commands/workspace.js +55 -0
  38. package/dist/cli/index.js +103 -0
  39. package/dist/cli/output.js +56 -0
  40. package/dist/cli/prompt.js +28 -0
  41. package/dist/cli/runtime.js +43 -0
  42. package/dist/config/config.js +103 -0
  43. package/dist/config/credentials.js +68 -0
  44. package/dist/config/paths.js +18 -0
  45. package/dist/config/server.js +21 -0
  46. package/dist/config/version.js +7 -0
  47. package/dist/data/local/activity.js +33 -0
  48. package/dist/data/local/clock.js +1 -0
  49. package/dist/data/local/comments.js +27 -0
  50. package/dist/data/local/index.js +48 -0
  51. package/dist/data/local/projects.js +53 -0
  52. package/dist/data/local/tasks.js +104 -0
  53. package/dist/data/local/users.js +38 -0
  54. package/dist/data/local/workspaces.js +55 -0
  55. package/dist/data/remote/api-client.js +103 -0
  56. package/dist/data/remote/live.js +93 -0
  57. package/dist/data/repositories.js +1 -0
  58. package/dist/data/sync/chat.js +242 -0
  59. package/dist/data/sync/engine.js +459 -0
  60. package/dist/data/sync/importer.js +156 -0
  61. package/dist/data/sync/index.js +109 -0
  62. package/dist/data/sync/services.js +263 -0
  63. package/dist/data/sync/store.js +200 -0
  64. package/dist/database/client.js +76 -0
  65. package/dist/database/migrate.js +27 -0
  66. package/dist/database/migrations/0000_init.sql +100 -0
  67. package/dist/database/migrations/0001_git-operations.sql +19 -0
  68. package/dist/database/migrations/0002_merge-detection.sql +19 -0
  69. package/dist/database/migrations/0003_sync-replica.sql +40 -0
  70. package/dist/database/migrations/0004_chat-replica.sql +52 -0
  71. package/dist/database/migrations/0005_pr-events.sql +18 -0
  72. package/dist/database/migrations/0006_archive.sql +20 -0
  73. package/dist/database/migrations/meta/0000_snapshot.json +710 -0
  74. package/dist/database/migrations/meta/0001_snapshot.json +717 -0
  75. package/dist/database/migrations/meta/0002_snapshot.json +724 -0
  76. package/dist/database/migrations/meta/0003_snapshot.json +972 -0
  77. package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
  78. package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
  79. package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
  80. package/dist/database/migrations/meta/_journal.json +55 -0
  81. package/dist/database/schema.js +170 -0
  82. package/dist/dev/seed.js +84 -0
  83. package/dist/domain/activity.js +22 -0
  84. package/dist/domain/chat.js +21 -0
  85. package/dist/domain/entities.js +1 -0
  86. package/dist/domain/errors.js +23 -0
  87. package/dist/domain/naming.js +33 -0
  88. package/dist/domain/task.js +140 -0
  89. package/dist/domain/workflow.js +23 -0
  90. package/dist/git/cli-git.js +363 -0
  91. package/dist/git/console.js +37 -0
  92. package/dist/git/diagnose.js +145 -0
  93. package/dist/git/merge-evidence.js +23 -0
  94. package/dist/git/releases.js +95 -0
  95. package/dist/git/types.js +16 -0
  96. package/dist/ui/App.js +71 -0
  97. package/dist/ui/Shell.js +211 -0
  98. package/dist/ui/app-state.js +213 -0
  99. package/dist/ui/branding/CompactLogo.js +8 -0
  100. package/dist/ui/branding/Logo.js +22 -0
  101. package/dist/ui/branding/Splash.js +21 -0
  102. package/dist/ui/branding/brand.js +23 -0
  103. package/dist/ui/chrome/Sidebar.js +50 -0
  104. package/dist/ui/chrome/StatusBar.js +26 -0
  105. package/dist/ui/chrome/Toast.js +11 -0
  106. package/dist/ui/chrome/TopBar.js +10 -0
  107. package/dist/ui/chrome/context.js +33 -0
  108. package/dist/ui/components/ConsoleLines.js +16 -0
  109. package/dist/ui/components/EmptyState.js +8 -0
  110. package/dist/ui/components/Labels.js +25 -0
  111. package/dist/ui/components/PullRequestLabel.js +27 -0
  112. package/dist/ui/components/TaskTable.js +41 -0
  113. package/dist/ui/components/TextField.js +11 -0
  114. package/dist/ui/components/TextInput.js +29 -0
  115. package/dist/ui/components/task-columns.js +27 -0
  116. package/dist/ui/copy.js +23 -0
  117. package/dist/ui/hooks/use-commands.js +58 -0
  118. package/dist/ui/hooks/use-flows.js +254 -0
  119. package/dist/ui/hooks/use-layout.js +36 -0
  120. package/dist/ui/hooks/use-list.js +28 -0
  121. package/dist/ui/hooks/use-measure.js +22 -0
  122. package/dist/ui/hooks/use-query.js +29 -0
  123. package/dist/ui/hooks/use-task-actions.js +450 -0
  124. package/dist/ui/hooks/use-terminal-size.js +19 -0
  125. package/dist/ui/input/KeyProvider.js +101 -0
  126. package/dist/ui/input/dispatcher.js +26 -0
  127. package/dist/ui/input/list-navigation.js +38 -0
  128. package/dist/ui/input/mouse.js +78 -0
  129. package/dist/ui/input/text-editing.js +62 -0
  130. package/dist/ui/kit/Badge.js +8 -0
  131. package/dist/ui/kit/Button.js +13 -0
  132. package/dist/ui/kit/Clickable.js +14 -0
  133. package/dist/ui/kit/Gauge.js +9 -0
  134. package/dist/ui/kit/Keycap.js +7 -0
  135. package/dist/ui/kit/Panel.js +30 -0
  136. package/dist/ui/kit/ScrollBar.js +14 -0
  137. package/dist/ui/kit/Spinner.js +9 -0
  138. package/dist/ui/kit/Tabs.js +12 -0
  139. package/dist/ui/mascot/AgentScene.js +30 -0
  140. package/dist/ui/mascot/Mascot.js +26 -0
  141. package/dist/ui/mascot/ansi.js +25 -0
  142. package/dist/ui/mascot/engine.js +146 -0
  143. package/dist/ui/mascot/index.js +6 -0
  144. package/dist/ui/mascot/palette.js +18 -0
  145. package/dist/ui/mascot/scenes/bugHunt.js +115 -0
  146. package/dist/ui/mascot/scenes/celebrate.js +76 -0
  147. package/dist/ui/mascot/scenes/cleaning.js +156 -0
  148. package/dist/ui/mascot/scenes/clockIn.js +105 -0
  149. package/dist/ui/mascot/scenes/deadline.js +125 -0
  150. package/dist/ui/mascot/scenes/empty.js +149 -0
  151. package/dist/ui/mascot/scenes/fire.js +138 -0
  152. package/dist/ui/mascot/scenes/index.js +25 -0
  153. package/dist/ui/mascot/scenes/planning.js +73 -0
  154. package/dist/ui/mascot/scenes/resting.js +90 -0
  155. package/dist/ui/mascot/scenes/syncing.js +116 -0
  156. package/dist/ui/mascot/scenes/working.js +193 -0
  157. package/dist/ui/mascot/selection.js +32 -0
  158. package/dist/ui/mascot/states.js +25 -0
  159. package/dist/ui/navigation/routes.js +17 -0
  160. package/dist/ui/overlays/CommitOverlay.js +93 -0
  161. package/dist/ui/overlays/ConfirmOverlay.js +42 -0
  162. package/dist/ui/overlays/GitLogOverlay.js +22 -0
  163. package/dist/ui/overlays/GitRunOverlay.js +94 -0
  164. package/dist/ui/overlays/NewTaskOverlay.js +117 -0
  165. package/dist/ui/overlays/OverlayFrame.js +35 -0
  166. package/dist/ui/overlays/PickerOverlay.js +72 -0
  167. package/dist/ui/overlays/PromptOverlay.js +51 -0
  168. package/dist/ui/overlays/SearchOverlay.js +49 -0
  169. package/dist/ui/overlays/options.js +29 -0
  170. package/dist/ui/overlays/types.js +1 -0
  171. package/dist/ui/screens/AccessScreen.js +91 -0
  172. package/dist/ui/screens/ChatScreen.js +450 -0
  173. package/dist/ui/screens/HelpScreen.js +99 -0
  174. package/dist/ui/screens/ProjectsScreen.js +66 -0
  175. package/dist/ui/screens/ScreenFrame.js +14 -0
  176. package/dist/ui/screens/SetupScreen.js +79 -0
  177. package/dist/ui/screens/TaskListScreen.js +152 -0
  178. package/dist/ui/screens/TaskScreen.js +193 -0
  179. package/dist/ui/screens/WelcomeScreen.js +254 -0
  180. package/dist/ui/screens/WorkspacesScreen.js +64 -0
  181. package/dist/ui/theme/detect.js +59 -0
  182. package/dist/ui/theme/theme.js +135 -0
  183. package/dist/utils/editor.js +37 -0
  184. package/dist/utils/errors.js +23 -0
  185. package/dist/utils/text.js +60 -0
  186. package/dist/utils/time.js +25 -0
  187. package/docs/NPM_PUBLISH.md +35 -0
  188. package/docs/PRIVACY.md +38 -0
  189. package/package.json +74 -0
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { palette } from '../theme/theme.js';
4
+ import { APP_SLOGAN, WORDMARK_WIDTH } from '../branding/brand.js';
5
+ import { Logo } from '../branding/Logo.js';
6
+ import { FOOTER_BRAND_GAP_ROWS, footerRowsFor } from '../hooks/use-layout.js';
7
+ /**
8
+ * The bottom bar: a pill with where you are, then the shortcuts that work
9
+ * right now as keys. Shortcuts that do not fit are dropped from the end.
10
+ */
11
+ export function StatusBar({ columns, mode, hints, right }) {
12
+ const footerRows = footerRowsFor(columns);
13
+ const inlineBrand = columns >= WORDMARK_WIDTH + APP_SLOGAN.length + 4;
14
+ const sloganWidth = inlineBrand ? columns - WORDMARK_WIDTH - 4 : columns - 2;
15
+ const available = columns - mode.length - 4 - (right ? right.length + 3 : 0);
16
+ const shown = [];
17
+ let used = 0;
18
+ for (const hint of hints) {
19
+ const size = hint[0].length + hint[1].length + 4;
20
+ if (used + size > available)
21
+ break;
22
+ shown.push(hint);
23
+ used += size;
24
+ }
25
+ return (_jsxs(Box, { width: columns, height: footerRows, flexDirection: "column", backgroundColor: palette.bar, flexShrink: 0, children: [_jsxs(Box, { width: columns, height: 1, backgroundColor: palette.bar, justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { backgroundColor: palette.bar, wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: palette.accent, color: palette.onAccent, bold: true, children: ` ${mode.toUpperCase()} ` }), shown.map(([keys, action]) => (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: palette.bar, children: " " }), _jsx(Text, { backgroundColor: palette.neutralSoft, color: palette.text, bold: true, children: ` ${keys} ` }), _jsx(Text, { backgroundColor: palette.bar, color: palette.muted, children: ` ${action}` })] }, keys + action)))] }), right ? (_jsx(Text, { backgroundColor: palette.bar, color: palette.faint, children: `${right} ` })) : null] }), _jsx(Box, { height: FOOTER_BRAND_GAP_ROWS, flexShrink: 0 }), _jsxs(Box, { width: columns, height: footerRows - 1 - FOOTER_BRAND_GAP_ROWS, paddingX: 1, backgroundColor: palette.bar, flexShrink: 0, flexDirection: inlineBrand ? 'row' : 'column', alignItems: inlineBrand ? 'center' : 'flex-start', justifyContent: "center", children: [_jsx(Logo, { cursorVisible: true }), _jsx(Box, { width: sloganWidth, marginLeft: inlineBrand ? 2 : 0, children: _jsx(Text, { backgroundColor: palette.bar, color: palette.muted, wrap: "wrap", children: APP_SLOGAN }) })] })] }));
26
+ }
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { palette, symbols, toneColors } from '../theme/theme.js';
4
+ /** The latest confirmation or error, floating above the status bar at the bottom right. */
5
+ export function Toast({ flash, columns, rows }) {
6
+ const tone = flash.tone === 'error' ? 'danger' : flash.tone === 'success' ? 'success' : 'info';
7
+ const { fg } = toneColors(tone);
8
+ const mark = flash.tone === 'error' ? symbols.cross : flash.tone === 'success' ? symbols.check : symbols.info;
9
+ const width = Math.min(columns - 4, Math.max(28, Math.min(64, Math.max(flash.text.length, flash.hint?.length ?? 0) + 8)));
10
+ return (_jsxs(Box, { position: "absolute", top: Math.max(1, rows - (flash.hint ? 6 : 5)), left: Math.max(0, columns - width - 2), width: width, borderStyle: "round", borderColor: fg, backgroundColor: palette.surface, flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { backgroundColor: palette.surface, wrap: "truncate-end", children: [_jsx(Text, { color: fg, bold: true, children: `${mark} ` }), _jsx(Text, { color: palette.text, children: flash.text })] }), flash.hint ? (_jsx(Text, { backgroundColor: palette.surface, color: palette.muted, wrap: "truncate-end", children: ` ${flash.hint}` })) : null] }));
11
+ }
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { APP_NAME, APP_VERSION } from '../branding/brand.js';
4
+ import { palette, symbols } from '../theme/theme.js';
5
+ /** 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
+ const roomy = columns >= 90;
8
+ 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
+ }
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
3
+ const ChromeContext = createContext(null);
4
+ /** Whether the screen (or window) around a component is the one on top. */
5
+ const ActiveContext = createContext(true);
6
+ export function ChromeProvider({ children }) {
7
+ const [hints, setState] = useState([]);
8
+ // Same shortcuts again (every render of a screen declares them) change nothing.
9
+ const setHints = useCallback((next) => setState((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next)), []);
10
+ const value = useMemo(() => ({ hints, setHints }), [hints, setHints]);
11
+ return _jsx(ChromeContext.Provider, { value: value, children: children });
12
+ }
13
+ export function ActiveScope({ active, children }) {
14
+ return _jsx(ActiveContext.Provider, { value: active, children: children });
15
+ }
16
+ export function useIsActive() {
17
+ return useContext(ActiveContext);
18
+ }
19
+ export function useChromeHints() {
20
+ return useContext(ChromeContext)?.hints ?? [];
21
+ }
22
+ /** The shortcuts shown in the status bar, while the screen or window that declares them is on top. */
23
+ export function useHints(hints) {
24
+ const setHints = useContext(ChromeContext)?.setHints;
25
+ const active = useIsActive();
26
+ const key = JSON.stringify(hints);
27
+ useLayoutEffect(() => {
28
+ if (active)
29
+ setHints?.(hints);
30
+ // `key` stands for the hints' content.
31
+ // eslint-disable-next-line react-hooks/exhaustive-deps
32
+ }, [active, key, setHints]);
33
+ }
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { palette } from '../theme/theme.js';
4
+ /**
5
+ * Git console lines on an inset like a small terminal: commands stand out,
6
+ * Git's chatter is dim, results are colored.
7
+ */
8
+ export function ConsoleLines({ lines, minRows = 0 }) {
9
+ return (_jsx(Box, { flexDirection: "column", backgroundColor: palette.bar, paddingX: 1, minHeight: minRows, children: lines.map((line) => (_jsx(Text, { wrap: "truncate-end", bold: line.kind === 'command', color: line.kind === 'command'
10
+ ? palette.accent
11
+ : line.kind === 'error'
12
+ ? palette.danger
13
+ : line.kind === 'success'
14
+ ? palette.success
15
+ : palette.muted, children: line.kind === 'command' ? line.text : ` ${line.text}` }, line.id))) }));
16
+ }
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { palette } from '../theme/theme.js';
4
+ /** Centered in the space it gets: the fact in normal text, the rest dimmer and with a little personality. */
5
+ export function EmptyState({ lines, icon = '◌' }) {
6
+ const [first, ...rest] = lines;
7
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, paddingY: 1, children: [_jsx(Text, { color: palette.faint, children: icon }), _jsx(Text, { color: palette.text, bold: true, children: first }), rest.filter(Boolean).map((line) => (_jsx(Text, { color: palette.muted, children: line }, line)))] }));
8
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ import { palette, priorityStyles, priorityTone, statusStyles, statusTone, toneColors } from '../theme/theme.js';
4
+ const LEVEL = { none: 0, low: 1, medium: 2, high: 3, urgent: 4 };
5
+ /** Priority as a small meter: `▰▰▰▱ HIGH`. The label can be dropped when space is short. */
6
+ export function PriorityMeter({ priority, label = true, background }) {
7
+ const { fg } = toneColors(priorityTone[priority]);
8
+ const level = LEVEL[priority];
9
+ const bg = background ? { backgroundColor: background } : {};
10
+ return (_jsxs(Text, { ...bg, children: [_jsx(Text, { color: level === 0 ? palette.faint : fg, children: '▰'.repeat(level) }), _jsx(Text, { color: palette.faint, children: '▱'.repeat(4 - level) }), label ? (_jsx(Text, { color: level === 0 ? palette.faint : fg, bold: priority === 'urgent', children: ` ${priority === 'none' ? '—' : priorityStyles[priority].label}` })) : null] }));
11
+ }
12
+ /** Status as a soft filled label: ` ◐ In Progress `, or only its glyph when narrow. */
13
+ export function StatusBadge({ status, glyphOnly = false }) {
14
+ const style = statusStyles[status];
15
+ const { fg, bg } = toneColors(statusTone[status]);
16
+ const color = status === 'todo' ? palette.text : fg;
17
+ return (_jsx(Text, { backgroundColor: bg, color: color, strikethrough: status === 'cancelled', children: glyphOnly ? ` ${style.glyph} ` : ` ${style.glyph} ${style.label} ` }));
18
+ }
19
+ /** Kept for places that show priority as a word (task detail header). */
20
+ export function PriorityLabel({ priority }) {
21
+ return _jsx(PriorityMeter, { priority: priority });
22
+ }
23
+ export function StatusLabel({ status, glyphOnly = false }) {
24
+ return _jsx(StatusBadge, { status: status, glyphOnly: glyphOnly });
25
+ }
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ import { palette, symbols } from '../theme/theme.js';
4
+ /** `#12✓`: number plus the one thing that matters most right now. For lists. */
5
+ export function PullRequestMark({ pr }) {
6
+ const mark = prMark(pr);
7
+ return (_jsx(Text, { color: mark.color, dimColor: mark.dim, children: `${symbols.pullRequest}${pr.number}${mark.glyph} ` }));
8
+ }
9
+ function prMark(pr) {
10
+ if (pr.state === 'merged')
11
+ return { glyph: symbols.check, color: palette.merged };
12
+ if (pr.state === 'closed')
13
+ return { glyph: symbols.cross, dim: true };
14
+ if (pr.checks.failed > 0 || pr.mergeable === 'conflicting' || pr.review === 'changes_requested')
15
+ return { glyph: symbols.cross, color: palette.danger };
16
+ if (pr.checks.pending > 0)
17
+ return { glyph: symbols.running, color: palette.warning };
18
+ if (pr.review === 'approved')
19
+ return { glyph: symbols.check, color: palette.success };
20
+ return { glyph: '', color: palette.info };
21
+ }
22
+ /** The full line for the task detail: state, review, checks, conflicts. */
23
+ export function PullRequestSummary({ pr }) {
24
+ const stateColor = pr.state === 'merged' ? palette.merged : pr.state === 'open' ? palette.info : undefined;
25
+ const { checks } = pr;
26
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: stateColor, dimColor: pr.state === 'closed', children: `${symbols.pullRequest}${pr.number} ${pr.draft ? 'draft' : pr.state}` }), pr.state === 'open' && checks.total > 0 ? (checks.failed > 0 ? (_jsx(Text, { color: palette.danger, children: ` ${symbols.cross} ${checks.failed} failing: ${checks.failing.join(', ')}` })) : checks.pending > 0 ? (_jsx(Text, { color: palette.warning, children: ` ${symbols.running} checks ${checks.passed}/${checks.total}, ${checks.pending} running` })) : (_jsx(Text, { color: palette.success, children: ` ${symbols.check} checks ${checks.passed}/${checks.total}` }))) : null, pr.state === 'open' && pr.mergeable === 'conflicting' ? _jsx(Text, { color: palette.danger, children: ' conflicts with base' }) : null, pr.state === 'open' && pr.review ? (_jsx(Text, { color: pr.review === 'approved' ? palette.success : pr.review === 'changes_requested' ? palette.danger : undefined, dimColor: pr.review === 'review_required', children: ` ${pr.review === 'approved' ? `${symbols.check} approved` : pr.review === 'changes_requested' ? `${symbols.cross} changes requested` : 'review required'}` })) : null, _jsx(Text, { color: palette.muted, children: ` → ${pr.base}` })] }));
27
+ }
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { Layer } from '../input/dispatcher.js';
4
+ import { Clickable } from '../kit/Clickable.js';
5
+ import { ScrollBar } from '../kit/ScrollBar.js';
6
+ import { palette, symbols } from '../theme/theme.js';
7
+ import { PriorityMeter, StatusBadge } from './Labels.js';
8
+ import { PullRequestMark } from './PullRequestLabel.js';
9
+ import { layoutTaskColumns } from './task-columns.js';
10
+ export function TaskTable({ tasks, selected, offset, height, width, showAssignee = false, showHeader = true, pullRequests, onSelect, onOpen, onScroll, layer = Layer.screen, active = true, }) {
11
+ const refWidth = Math.max(8, ...tasks.map((task) => task.ref.length));
12
+ const columns = layoutTaskColumns(width, { showAssignee, refWidth });
13
+ const rows = tableRows(height, showHeader);
14
+ const visible = tasks.slice(offset, offset + rows);
15
+ return (_jsx(Clickable, { onClick: (event, rect) => {
16
+ // Rows are separate hit regions; this table-level fallback also handles
17
+ // terminals that report a point on a text cell outside its row bounds.
18
+ const row = Math.floor(event.y - rect.y) - (showHeader ? 1 : 0);
19
+ const index = offset + row;
20
+ if (row < 0 || row >= visible.length)
21
+ return;
22
+ if (index === selected)
23
+ onOpen?.(index);
24
+ else
25
+ onSelect?.(index);
26
+ }, onWheel: onScroll, layer: layer, active: active, flexDirection: "column", width: width, height: height, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [showHeader ? (_jsxs(Box, { children: [_jsx(Box, { width: 2 }), _jsx(Cell, { width: columns.ref, text: "ID" }), _jsx(Cell, { width: columns.priority, text: columns.priorityLabel ? "PRIORITY" : "PRI" }), _jsx(Cell, { width: columns.status, text: columns.statusGlyphOnly ? '' : 'STATUS' }), columns.project ? _jsx(Cell, { width: columns.project, text: "PROJECT" }) : null, columns.assignee ? _jsx(Cell, { width: columns.assignee, text: "ASSIGNEE" }) : null, _jsx(Cell, { width: columns.title, text: "TITLE" })] })) : null, _jsxs(Box, { children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, children: visible.map((task, position) => {
27
+ const index = offset + position;
28
+ const isSelected = index === selected;
29
+ const pr = pullRequests?.get(task.id);
30
+ const bg = isSelected ? { backgroundColor: palette.selection } : {};
31
+ const closed = task.status === 'cancelled' || task.status === 'done';
32
+ return (_jsx(Clickable, { layer: layer, active: active, width: "100%", onClick: () => (isSelected ? onOpen?.(index) : onSelect?.(index)), children: _jsxs(Box, { flexGrow: 1, ...bg, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: palette.accent, ...bg, children: isSelected ? `${symbols.pointer} ` : ' ' }) }), _jsx(Box, { width: columns.ref, flexShrink: 0, children: _jsx(Text, { color: isSelected ? palette.accent : palette.muted, bold: isSelected, ...bg, children: task.ref }) }), _jsx(Box, { width: columns.priority, flexShrink: 0, children: _jsx(PriorityMeter, { priority: task.priority, label: columns.priorityLabel, background: bg.backgroundColor }) }), _jsx(Box, { width: columns.status, flexShrink: 0, children: _jsx(StatusBadge, { status: task.status, glyphOnly: columns.statusGlyphOnly }) }), columns.project ? (_jsx(Box, { width: columns.project, flexShrink: 0, paddingRight: 1, children: _jsx(Text, { color: task.project ? palette.muted : palette.faint, wrap: "truncate-end", ...bg, children: task.project?.name ?? '—' }) })) : null, columns.assignee ? (_jsx(Box, { width: columns.assignee, flexShrink: 0, paddingRight: 1, children: _jsx(Text, { color: palette.muted, wrap: "truncate-end", ...bg, children: task.assignee ? `@${task.assignee.username}` : '—' }) })) : null, _jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsxs(Text, { wrap: "truncate-end", ...bg, children: [task.archivedAt ? _jsx(Text, { color: palette.faint, children: 'archived · ' }) : null, pr ? _jsx(PullRequestMark, { pr: pr }) : null, _jsx(Text, { color: closed ? palette.muted : palette.text, bold: isSelected, strikethrough: task.status === 'cancelled', children: task.title })] }) })] }) }, task.id));
33
+ }) }), _jsx(ScrollBar, { total: tasks.length, visible: rows, offset: offset, height: Math.min(rows, tasks.length) })] })] }) }));
34
+ }
35
+ function Cell({ width, text }) {
36
+ return (_jsx(Box, { width: width, paddingRight: 1, flexShrink: 0, children: _jsx(Text, { color: palette.faint, bold: true, wrap: "truncate-end", children: text }) }));
37
+ }
38
+ /** Task rows that fit in `height`, which is what list navigation must scroll by. */
39
+ export function tableRows(height, showHeader = true) {
40
+ return Math.max(1, height - (showHeader ? 1 : 0));
41
+ }
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { palette } from '../theme/theme.js';
4
+ import { TextInput } from './TextInput.js';
5
+ /**
6
+ * A text box that looks like a form field: a filled line with the cursor,
7
+ * an optional label above it. Editing rules are TextInput's.
8
+ */
9
+ export function TextField({ value, onChange, placeholder, active = true, label, width, }) {
10
+ return (_jsxs(Box, { flexDirection: "column", children: [label ? _jsx(Text, { color: active ? palette.accent : palette.muted, children: label }) : null, _jsx(Box, { backgroundColor: palette.neutralSoft, paddingX: 1, ...(width ? { width } : {}), children: _jsx(TextInput, { value: value, onChange: onChange, placeholder: placeholder, active: active, prompt: active ? '›' : ' ' }) })] }));
11
+ }
@@ -0,0 +1,29 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ import { useState } from 'react';
4
+ import { useKeys } from '../input/KeyProvider.js';
5
+ import { Layer } from '../input/dispatcher.js';
6
+ import { editText } from '../input/text-editing.js';
7
+ import { palette } from '../theme/theme.js';
8
+ /**
9
+ * Single-line field. Consumes editing keys only; Enter, Esc, Tab and ↑/↓
10
+ * fall through to whoever owns the field.
11
+ */
12
+ export function TextInput({ value, onChange, placeholder, active = true, prompt = '›' }) {
13
+ // Stored as distance from the end: when the owner replaces the value
14
+ // (autocomplete), a cursor at the end stays at the end.
15
+ const [fromEnd, setFromEnd] = useState(0);
16
+ const cursor = Math.max(0, value.length - fromEnd);
17
+ useKeys(Layer.input, (input, key) => {
18
+ const next = editText({ value, cursor }, input, key);
19
+ if (!next)
20
+ return false;
21
+ setFromEnd(next.value.length - next.cursor);
22
+ if (next.value !== value)
23
+ onChange(next.value);
24
+ return true;
25
+ }, active);
26
+ const position = cursor;
27
+ const showPlaceholder = !value && placeholder;
28
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: active ? palette.accent : undefined, dimColor: !active, children: [prompt, ' '] }), showPlaceholder ? (_jsxs(_Fragment, { children: [active ? _jsx(Text, { inverse: true, children: placeholder.slice(0, 1) }) : null, _jsx(Text, { dimColor: true, children: active ? placeholder.slice(1) : placeholder })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { children: value.slice(0, position) }), active ? _jsx(Text, { inverse: true, children: value.slice(position, position + 1) || ' ' }) : null, _jsx(Text, { children: active ? value.slice(position + 1) : value.slice(position) })] }))] }));
29
+ }
@@ -0,0 +1,27 @@
1
+ const POINTER = 2;
2
+ /** The scroll bar column on the right. */
3
+ const SCROLL = 1;
4
+ /**
5
+ * Drops detail as the space narrows: assignee first, then the priority word,
6
+ * then the status label (the colored glyph stays), then the project. ID,
7
+ * priority meter and title always remain.
8
+ */
9
+ export function layoutTaskColumns(width, options) {
10
+ const ref = options.refWidth + 2;
11
+ const wide = width >= 100;
12
+ const medium = width >= 80;
13
+ const narrow = width >= 64;
14
+ const columns = {
15
+ ref,
16
+ priority: medium ? 13 : 6,
17
+ priorityLabel: medium,
18
+ project: medium ? 15 : narrow ? 13 : 0,
19
+ status: medium ? 17 : 5,
20
+ statusGlyphOnly: !medium,
21
+ assignee: wide && options.showAssignee ? 11 : 0,
22
+ title: 0,
23
+ };
24
+ const used = POINTER + SCROLL + columns.ref + columns.priority + columns.project + columns.status + columns.assignee;
25
+ columns.title = Math.max(10, width - used);
26
+ return columns;
27
+ }
@@ -0,0 +1,23 @@
1
+ /** Empty states. First line is the fact; the rest may smile a little. */
2
+ export const EMPTY_TASKS = {
3
+ mine: ['No tasks assigned.', "You're free.", 'Press n to ruin that.'],
4
+ all: ['No open tasks in this workspace.', 'Suspiciously peaceful.'],
5
+ todo: ['Nothing waiting in Todo.'],
6
+ in_progress: ['Nothing in progress.', 'Pick something up with s on a task.'],
7
+ review: ['Nothing waiting for review.'],
8
+ blocked: ['Nothing blocked.', 'Keep it that way.'],
9
+ done: ['Nothing done yet.', 'The day is young.'],
10
+ archived: ['Nothing archived.', 'Archive a task from its Edit menu (e).'],
11
+ };
12
+ export const EMPTY_SEARCH = ['Nothing matches.', 'Maybe it is still in a WhatsApp chat.'];
13
+ export const EMPTY_PROJECTS = ['No projects yet.', 'Press n to add the first one.'];
14
+ /** One dry remark for the summary line, or null. Never more than one. */
15
+ export function summaryRemark(counts) {
16
+ if (counts.blocked >= 3)
17
+ return 'That is a lot of walls.';
18
+ if (counts.review >= 3)
19
+ return 'Someone has homework.';
20
+ if (counts.in_progress >= 5)
21
+ return 'Many plates spinning.';
22
+ return null;
23
+ }
@@ -0,0 +1,58 @@
1
+ import { FILTER_LABELS, TASK_FILTERS } from '../../application/filters.js';
2
+ import { useAppState } from '../app-state.js';
3
+ import { useFlows } from './use-flows.js';
4
+ /** The command palette: every major action, filterable by typing. */
5
+ export function useCommandPalette(newTaskProjectId) {
6
+ const { openOverlay, go, quit, services, session, notify, setHomeFilter } = useAppState();
7
+ // Views of home: the filter is shared app state (the sidebar shows it too).
8
+ const showView = (filter) => {
9
+ setHomeFilter(filter);
10
+ go({ type: 'reset' });
11
+ };
12
+ const flows = useFlows();
13
+ return () => {
14
+ const commands = [
15
+ { id: 'new-task', label: 'New task', key: 'n', run: () => openOverlay({ kind: 'new-task', projectId: newTaskProjectId }) },
16
+ ...TASK_FILTERS.map((filter) => ({
17
+ id: `filter-${filter}`,
18
+ label: FILTER_LABELS[filter],
19
+ run: () => showView(filter),
20
+ })),
21
+ { id: 'filter-archived', label: 'Archived tasks', run: () => showView('archived') },
22
+ { id: 'search', label: 'Search', key: '/', run: () => openOverlay({ kind: 'search' }) },
23
+ { id: 'projects', label: 'Projects', key: 'p', run: () => go({ type: 'push', route: { name: 'projects' } }) },
24
+ { id: 'switch-project', label: 'Switch project', run: () => flows.pickProject() },
25
+ { id: 'new-project', label: 'New project', run: () => flows.newProject() },
26
+ { id: 'switch-workspace', label: 'Switch workspace', run: () => flows.pickWorkspace() },
27
+ { id: 'workspaces', label: 'Workspaces', key: 'w', run: () => go({ type: 'push', route: { name: 'workspaces' } }) },
28
+ { id: 'new-workspace', label: 'New workspace', run: () => flows.newWorkspace() },
29
+ { id: 'add-developer', label: 'Add developer', run: () => flows.addDeveloper() },
30
+ { id: 'parent-folders', label: 'Parent folders', run: () => flows.manageParentFolders() },
31
+ { id: 'git-log', label: 'Git log', run: () => openOverlay({ kind: 'git-log' }) },
32
+ ...(services.chat ? [{ id: 'chat', label: 'Chat', key: '#', run: () => go({ type: 'push', route: { name: 'chat' } }) }] : []),
33
+ ...(services.sync
34
+ ? [{
35
+ id: 'sync',
36
+ label: 'Sync now',
37
+ run: async () => {
38
+ await services.sync?.syncNow(session.workspace.id);
39
+ notify('Synced', 'success');
40
+ },
41
+ }]
42
+ : []),
43
+ { id: 'help', label: 'Help', key: '?', run: () => go({ type: 'push', route: { name: 'help' } }) },
44
+ { id: 'quit', label: 'Quit', key: 'q', run: quit },
45
+ ];
46
+ const spec = {
47
+ kind: 'picker',
48
+ title: 'Commands',
49
+ options: commands.map((command) => ({ value: command.id, label: command.label, ...(command.key ? { hint: command.key } : {}) })),
50
+ filterable: true,
51
+ onSelect: (id) => {
52
+ // Commands may open their own overlay; the palette only closes itself if still on top.
53
+ void commands.find((command) => command.id === id)?.run();
54
+ },
55
+ };
56
+ openOverlay(spec);
57
+ };
58
+ }
@@ -0,0 +1,254 @@
1
+ import { basename } from 'node:path';
2
+ import { deriveProjectKey } from '../../domain/naming.js';
3
+ import { toDisplayError } from '../../utils/errors.js';
4
+ import { tildify } from '../../utils/text.js';
5
+ import { useAppState } from '../app-state.js';
6
+ const ADD = '__add-parent__';
7
+ const TYPE = '__type-path__';
8
+ const UNLINK = '__unlink__';
9
+ const MISSING = '__missing__:';
10
+ /** Workspace-level flows, shared by their screens and the command palette. */
11
+ export function useFlows() {
12
+ const { services, session, setSession, openOverlay, run, notify, go, cwd } = useAppState();
13
+ const switchWorkspace = async (id) => {
14
+ if (id === session.workspace.id)
15
+ return true;
16
+ let next;
17
+ const ok = await run(async () => {
18
+ next = await services.workspaces.switchTo(session.user, id);
19
+ });
20
+ if (ok && next) {
21
+ setSession(next);
22
+ notify(`Now in ${next.workspace.name}`, 'success');
23
+ }
24
+ return ok;
25
+ };
26
+ /** Adds a parent folder, then continues with `next` (e.g. reopening the picker). */
27
+ const addParentFolder = (next) => openOverlay({
28
+ kind: 'prompt',
29
+ title: 'Add parent folder',
30
+ context: 'a folder that contains your repositories',
31
+ placeholder: '~/workspace/products',
32
+ onSubmit: async (path) => {
33
+ const ok = await run(async () => services.folders.add(path), `Added ${path.trim()}`);
34
+ if (ok && next)
35
+ await next();
36
+ return ok;
37
+ },
38
+ });
39
+ const typeRepositoryPath = (project) => openOverlay({
40
+ kind: 'prompt',
41
+ title: 'Repository path',
42
+ context: project.name,
43
+ initial: project.repositoryPath ?? cwd,
44
+ onSubmit: (path) => run(() => services.projects.linkRepository(session, project, path), `${project.name} linked to ${path.trim()}`),
45
+ });
46
+ /**
47
+ * Visual repository picker: every subfolder of every parent folder, like
48
+ * `ls -1`, filterable by typing. Git repositories are marked; folders
49
+ * already linked to another project say so.
50
+ */
51
+ const pickRepository = async (project) => {
52
+ let parents;
53
+ try {
54
+ parents = services.folders.browse();
55
+ }
56
+ catch (error) {
57
+ const display = toDisplayError(error);
58
+ notify(display.message, 'error', display.hint);
59
+ return;
60
+ }
61
+ if (parents.length === 0) {
62
+ addParentFolder(() => pickRepository(project));
63
+ return;
64
+ }
65
+ const projects = await services.projects.list(session);
66
+ const linkedTo = new Map(projects.flatMap((p) => (p.repositoryPath && p.id !== project.id ? [[p.repositoryPath, p.name]] : [])));
67
+ const names = parents.map((parent) => basename(parent.path));
68
+ const parentLabel = (path) => {
69
+ const name = basename(path);
70
+ return names.filter((candidate) => candidate === name).length > 1 ? tildify(path) : name;
71
+ };
72
+ const folders = parents.flatMap((parent) => parent.available
73
+ ? parent.children.map((child) => {
74
+ const other = linkedTo.get(child.path);
75
+ return {
76
+ value: child.path,
77
+ label: `${parentLabel(parent.path)}/${child.name}`,
78
+ hint: !child.isGitRepository ? 'not a Git repository' : other ? `git · linked to ${other}` : 'git',
79
+ dim: !child.isGitRepository,
80
+ };
81
+ })
82
+ : [{ value: `${MISSING}${parent.path}`, label: `${tildify(parent.path)} (folder not found)`, dim: true }]);
83
+ const actions = [
84
+ { value: ADD, label: '+ Add parent folder…', hint: `${parents.length} registered` },
85
+ { value: TYPE, label: 'Type a path…' },
86
+ ...(project.repositoryPath ? [{ value: UNLINK, label: 'Unlink repository', hint: tildify(project.repositoryPath) }] : []),
87
+ ];
88
+ openOverlay({
89
+ kind: 'picker',
90
+ title: `Repository for ${project.name}`,
91
+ context: 'type to filter',
92
+ options: [...folders, ...actions],
93
+ initial: project.repositoryPath,
94
+ filterable: true,
95
+ onSelect: (value) => {
96
+ if (value === ADD)
97
+ return void addParentFolder(() => pickRepository(project));
98
+ if (value === TYPE)
99
+ return void typeRepositoryPath(project);
100
+ if (value === UNLINK)
101
+ return run(() => services.projects.unlinkRepository(session, project), `${project.name} unlinked`);
102
+ if (value.startsWith(MISSING)) {
103
+ notify('That parent folder no longer exists.', 'error', 'Remove it from Parent folders in the command palette.');
104
+ return false;
105
+ }
106
+ if (!folders.find((option) => option.value === value && !option.dim)) {
107
+ notify(`${tildify(value)} is not a Git repository.`, 'error', 'Run `git init` there, or pick another folder.');
108
+ return false;
109
+ }
110
+ return run(() => services.projects.linkRepository(session, project, value), `${project.name} linked to ${tildify(value)}`);
111
+ },
112
+ });
113
+ };
114
+ const prompt = (project, field, title, placeholder) => openOverlay({
115
+ kind: 'prompt',
116
+ title,
117
+ context: `${project.key} · ${project.name}`,
118
+ initial: project[field] ?? '',
119
+ placeholder,
120
+ allowEmpty: field !== 'name',
121
+ onSubmit: (value) => run(() => services.projects.update(session, project, { [field]: value }), `${project.key} updated`),
122
+ });
123
+ return {
124
+ switchWorkspace,
125
+ pickRepository,
126
+ addParentFolder,
127
+ /** Name, description and repository URL. The key stays (people and scripts use it). */
128
+ editProject(project) {
129
+ openOverlay({
130
+ kind: 'picker',
131
+ title: 'Edit project',
132
+ context: `${project.key} · ${project.name}`,
133
+ options: [
134
+ { value: 'name', label: 'Name', hint: project.name },
135
+ { value: 'description', label: 'Description', hint: project.description ?? '—' },
136
+ { value: 'repositoryUrl', label: 'Repository URL', hint: project.repositoryUrl ?? '—' },
137
+ { value: 'repo', label: 'Linked folder on this machine…', hint: project.repositoryPath ? tildify(project.repositoryPath) : 'r' },
138
+ ],
139
+ onSelect: (value) => {
140
+ if (value === 'name')
141
+ prompt(project, 'name', 'Project name', 'EnrollBridge');
142
+ else if (value === 'description')
143
+ prompt(project, 'description', 'Description', 'What this project is');
144
+ else if (value === 'repositoryUrl')
145
+ prompt(project, 'repositoryUrl', 'Repository URL', 'https://github.com/org/repo');
146
+ else if (value === 'repo')
147
+ void pickRepository(project);
148
+ },
149
+ });
150
+ },
151
+ /** Lists parent folders; choosing one offers to remove it. */
152
+ manageParentFolders() {
153
+ const parents = services.folders.browse();
154
+ openOverlay({
155
+ kind: 'picker',
156
+ title: 'Parent folders',
157
+ context: 'where your repositories live',
158
+ options: [
159
+ ...parents.map((parent) => ({
160
+ value: parent.path,
161
+ label: tildify(parent.path),
162
+ hint: parent.available ? `${parent.children.length} folders` : 'not found',
163
+ dim: !parent.available,
164
+ })),
165
+ { value: ADD, label: '+ Add parent folder…' },
166
+ ],
167
+ filterable: parents.length > 8,
168
+ onSelect: (value) => {
169
+ if (value === ADD)
170
+ return void addParentFolder();
171
+ openOverlay({
172
+ kind: 'picker',
173
+ title: `Remove ${tildify(value)}?`,
174
+ context: 'only forgets it; nothing is deleted from disk',
175
+ options: [
176
+ { value: 'remove', label: 'Remove from parent folders' },
177
+ { value: 'cancel', label: 'Cancel', dim: true },
178
+ ],
179
+ onSelect: (answer) => answer === 'remove' ? run(async () => services.folders.remove(value), `Removed ${tildify(value)}`) : undefined,
180
+ });
181
+ },
182
+ });
183
+ },
184
+ newProject() {
185
+ openOverlay({
186
+ kind: 'prompt',
187
+ title: 'New project',
188
+ context: session.workspace.name,
189
+ placeholder: 'EnrollBridge',
190
+ // The key prompt replaces this one: confirm or change the derived key.
191
+ onSubmit: (name) => openOverlay({
192
+ kind: 'prompt',
193
+ title: 'Project key',
194
+ context: name.trim(),
195
+ initial: deriveProjectKey(name),
196
+ onSubmit: (key) => run(() => services.projects.create(session, { name, key }), `Project ${name.trim()} created`),
197
+ }),
198
+ });
199
+ },
200
+ addDeveloper() {
201
+ const remote = services.environment.mode === 'remote';
202
+ openOverlay({
203
+ kind: 'prompt',
204
+ title: 'Add developer',
205
+ context: remote ? `${session.workspace.name} · GitHub login of someone who ran soja login` : session.workspace.name,
206
+ placeholder: 'username, e.g. angel',
207
+ onSubmit: (username) => remote
208
+ ? run(() => services.workspaces.addMember(session, { username }), `@${username.trim().replace(/^@/, '').toLowerCase()} joined ${session.workspace.name}`)
209
+ : openOverlay({
210
+ kind: 'prompt',
211
+ title: 'Display name',
212
+ context: `@${username.trim().toLowerCase()}`,
213
+ initial: username.trim().replace(/^./, (char) => char.toUpperCase()),
214
+ onSubmit: (displayName) => run(() => services.workspaces.addMember(session, { username, displayName }), `@${username.trim().toLowerCase()} joined ${session.workspace.name}`),
215
+ }),
216
+ });
217
+ },
218
+ newWorkspace() {
219
+ openOverlay({
220
+ kind: 'prompt',
221
+ title: 'New workspace',
222
+ placeholder: 'Freelance',
223
+ onSubmit: async (name) => {
224
+ let createdId;
225
+ const ok = await run(async () => {
226
+ createdId = (await services.workspaces.create(session.user, { name })).id;
227
+ });
228
+ return ok && createdId ? switchWorkspace(createdId) : ok;
229
+ },
230
+ });
231
+ },
232
+ async pickWorkspace() {
233
+ const workspaces = await services.workspaces.list(session.user);
234
+ openOverlay({
235
+ kind: 'picker',
236
+ title: 'Switch workspace',
237
+ options: workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, hint: workspace.slug })),
238
+ initial: session.workspace.id,
239
+ filterable: true,
240
+ onSelect: switchWorkspace,
241
+ });
242
+ },
243
+ async pickProject() {
244
+ const projects = await services.projects.list(session);
245
+ openOverlay({
246
+ kind: 'picker',
247
+ title: 'Switch project',
248
+ options: projects.map((project) => ({ value: project.id, label: project.name, hint: `${project.key} · ${project.active} active` })),
249
+ filterable: true,
250
+ onSelect: (projectId) => go({ type: 'reset', route: { name: 'project', projectId } }),
251
+ });
252
+ },
253
+ };
254
+ }