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,79 @@
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 { slugify } from '../../domain/naming.js';
5
+ import { toDisplayError } from '../../utils/errors.js';
6
+ import { Splash } from '../branding/Splash.js';
7
+ import { TextField } from '../components/TextField.js';
8
+ import { useTerminalSize } from '../hooks/use-terminal-size.js';
9
+ import { Button } from '../kit/Button.js';
10
+ import { Gauge } from '../kit/Gauge.js';
11
+ import { Panel } from '../kit/Panel.js';
12
+ import { Spinner } from '../kit/Spinner.js';
13
+ import { Layer } from '../input/dispatcher.js';
14
+ import { useKeys } from '../input/KeyProvider.js';
15
+ import { palette, symbols } from '../theme/theme.js';
16
+ const STEPS = ['displayName', 'username', 'workspaceName'];
17
+ const QUESTIONS = {
18
+ displayName: "What's your name?",
19
+ username: 'Username',
20
+ workspaceName: 'Workspace name',
21
+ };
22
+ const PLACEHOLDERS = {
23
+ displayName: 'Michael',
24
+ username: 'michael',
25
+ workspaceName: 'Bravos Development',
26
+ };
27
+ /** First run: three questions, then straight into SOJA. */
28
+ export function SetupScreen({ services, onDone }) {
29
+ const [step, setStep] = useState(0);
30
+ const [values, setValues] = useState({ displayName: '', username: '', workspaceName: '' });
31
+ const [error, setError] = useState(null);
32
+ const [busy, setBusy] = useState(false);
33
+ const current = STEPS[step] ?? 'displayName';
34
+ const advance = async () => {
35
+ if (busy)
36
+ return;
37
+ if (!values[current].trim()) {
38
+ setError('This one is required.');
39
+ return;
40
+ }
41
+ setError(null);
42
+ if (current === 'displayName' && !values.username) {
43
+ // Suggest a username from the name; it stays editable.
44
+ setValues((previous) => ({ ...previous, username: slugify(previous.displayName).replace(/-/g, '') }));
45
+ }
46
+ if (step < STEPS.length - 1) {
47
+ setStep(step + 1);
48
+ return;
49
+ }
50
+ setBusy(true);
51
+ try {
52
+ onDone(await services.session.setup(values));
53
+ }
54
+ catch (failure) {
55
+ setError(toDisplayError(failure).message);
56
+ setBusy(false);
57
+ }
58
+ };
59
+ useKeys(Layer.screen, (_input, key) => {
60
+ if (key.return)
61
+ void advance();
62
+ else if (key.escape && step > 0) {
63
+ setError(null);
64
+ setStep(step - 1);
65
+ }
66
+ else
67
+ return false;
68
+ return true;
69
+ });
70
+ const { columns, rows } = useTerminalSize();
71
+ const width = Math.min(76, columns - 4);
72
+ const stepTitles = { displayName: 'Your name', username: 'Username', workspaceName: 'Workspace' };
73
+ const helpers = {
74
+ displayName: 'How your teammates see you in SOJA.',
75
+ username: 'Used in @mentions and assignments. Letters, numbers and dashes.',
76
+ workspaceName: 'Your team or company. You can create more later.',
77
+ };
78
+ return (_jsx(Box, { width: columns, height: rows, alignItems: "center", justifyContent: "center", children: _jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Splash, { compact: true }) }), _jsxs(Panel, { title: "First setup", aside: `step ${step + 1} of ${STEPS.length}`, focused: true, width: width, children: [_jsx(Box, { gap: 1, marginBottom: 1, children: STEPS.map((name, index) => (_jsxs(Box, { gap: 1, children: [index > 0 ? _jsx(Text, { color: index <= step ? palette.accent : palette.border, children: '──' }) : null, _jsx(Text, { ...(index === step ? { backgroundColor: palette.accent, color: palette.onAccent, bold: true } : { color: index < step ? palette.success : palette.faint }), children: ` ${index < step ? symbols.check : String(index + 1)} ${stepTitles[name]} ` })] }, name))) }), _jsx(Text, { color: palette.text, bold: true, children: QUESTIONS[current] }), _jsx(Text, { color: palette.muted, children: helpers[current] }), _jsx(Box, { marginTop: 1, children: _jsx(TextField, { value: values[current], onChange: (value) => setValues((previous) => ({ ...previous, [current]: value })), placeholder: PLACEHOLDERS[current], width: width - 6 }) }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: STEPS.slice(0, step).map((name) => (_jsx(Text, { color: palette.muted, children: `${symbols.check} ${stepTitles[name]}: ${values[name]}` }, name))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", children: [error ? (_jsx(Text, { color: palette.danger, children: `${symbols.cross} ${error}` })) : busy ? (_jsx(Spinner, { label: "Setting things up\u2026" })) : (_jsx(Gauge, { value: step, total: STEPS.length, width: 16 })), _jsxs(Box, { gap: 2, children: [step > 0 ? _jsx(Button, { label: "Back", layer: Layer.screen, onPress: () => { setError(null); setStep(step - 1); } }) : null, _jsx(Button, { label: step === STEPS.length - 1 ? 'Finish' : `Next ${symbols.arrow}`, variant: "primary", layer: Layer.screen, onPress: () => void advance() })] })] })] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.faint, children: "Everything stays on this machine \u00B7 enter next \u00B7 esc back \u00B7 ctrl+c quit" }) })] }) }));
79
+ }
@@ -0,0 +1,152 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { FILTER_LABELS, TASK_FILTERS } from '../../application/filters.js';
5
+ import { activeCount, countStatuses, TYPE_LABELS } from '../../domain/task.js';
6
+ import { formatRelative } from '../../utils/time.js';
7
+ import { useAppState } from '../app-state.js';
8
+ import { EmptyState } from '../components/EmptyState.js';
9
+ import { PriorityMeter, StatusBadge } from '../components/Labels.js';
10
+ import { PullRequestSummary } from '../components/PullRequestLabel.js';
11
+ import { TaskTable, tableRows } from '../components/TaskTable.js';
12
+ import { EMPTY_TASKS, summaryRemark } from '../copy.js';
13
+ import { useLayout } from '../hooks/use-layout.js';
14
+ import { useList } from '../hooks/use-list.js';
15
+ import { useQuery } from '../hooks/use-query.js';
16
+ import { useTaskActions } from '../hooks/use-task-actions.js';
17
+ import { Layer } from '../input/dispatcher.js';
18
+ import { useKeys } from '../input/KeyProvider.js';
19
+ import { Panel } from '../kit/Panel.js';
20
+ import { Tabs } from '../kit/Tabs.js';
21
+ import { palette, symbols } from '../theme/theme.js';
22
+ import { Mascot } from '../mascot/Mascot.js';
23
+ import { sceneForTaskList } from '../mascot/selection.js';
24
+ import { clampLines, wrapText } from '../../utils/text.js';
25
+ import { ScreenFrame } from './ScreenFrame.js';
26
+ const HEADING = { ...FILTER_LABELS, mine: 'My work' };
27
+ const TAB_LABELS = { ...FILTER_LABELS, mine: 'Mine', all: 'All' };
28
+ const NO_TASKS = [];
29
+ /** Rows of the preview panel, including its borders. */
30
+ const PREVIEW_ROWS = 8;
31
+ /** "What do I have to do now?" Home is this screen with no project. */
32
+ export function TaskListScreen({ active, projectId, initialFilter }) {
33
+ const { services, session, go, cwd, homeFilter, setHomeFilter, syncStatus } = useAppState();
34
+ const actions = useTaskActions();
35
+ const { width, height } = useLayout();
36
+ // Home shares its filter with the sidebar; a project list keeps its own.
37
+ const [projectFilter, setProjectFilter] = useState(initialFilter ?? 'all');
38
+ const filter = projectId ? projectFilter : homeFilter;
39
+ const setFilter = projectId ? setProjectFilter : setHomeFilter;
40
+ const scope = projectId ? { projectId } : {};
41
+ const tasks = useQuery(() => services.tasks.list(session, filter, scope), `tasks:${session.workspace.id}:${filter}:${projectId ?? ''}`, [`tasks:${session.workspace.id}`]);
42
+ // GitHub pull requests of the listed tasks (one cached `gh` call per repository).
43
+ const withBranch = (tasks.data ?? []).filter((task) => task.branch);
44
+ const pullRequests = useQuery(() => services.git.pullRequestIndex(session, cwd, withBranch), `prs:${withBranch.map((task) => `${task.id}:${task.branch ?? ''}`).join(',')}`);
45
+ const project = useQuery(async () => (projectId ? ((await services.projects.list(session)).find((p) => p.id === projectId) ?? null) : null), `project:${projectId ?? ''}`, [`projects:${session.workspace.id}`, `tasks:${session.workspace.id}`]);
46
+ const list = tasks.data ?? NO_TASKS;
47
+ // Tabs and a gap; the project line; the preview panel when there is room for it.
48
+ const withPreview = height >= 22 && list.length > 0;
49
+ const showMascot = width >= 80 && height >= 34;
50
+ const mascotRows = showMascot ? 15 : 0;
51
+ const chromeRows = 2 + (projectId ? 2 : 0) + (withPreview ? PREVIEW_ROWS : 0);
52
+ const tableHeight = Math.max(3, height - chromeRows - mascotRows);
53
+ const selectedId = useRef(null);
54
+ const nav = useList(list.length, tableRows(tableHeight), true, 0, (index) => {
55
+ selectedId.current = list[index]?.id ?? null;
56
+ });
57
+ const selected = list[nav.index];
58
+ useEffect(() => {
59
+ if (tasks.loading)
60
+ return;
61
+ const remembered = selectedId.current;
62
+ if (remembered) {
63
+ const nextIndex = list.findIndex((task) => task.id === remembered);
64
+ if (nextIndex >= 0) {
65
+ if (nextIndex !== nav.index)
66
+ nav.select(nextIndex);
67
+ selectedId.current = list[nextIndex]?.id ?? null;
68
+ return;
69
+ }
70
+ }
71
+ selectedId.current = list[nav.index]?.id ?? null;
72
+ }, [tasks.loading, tasks.data, nav.index, list, nav]);
73
+ const pick = (next) => {
74
+ selectedId.current = null;
75
+ setFilter(next);
76
+ nav.select(0);
77
+ // Filter changes intentionally reset to the first result, regardless of
78
+ // which task occupied index zero in the previous result set.
79
+ selectedId.current = null;
80
+ };
81
+ const setFilterAt = (index) => {
82
+ const next = TASK_FILTERS[(index + TASK_FILTERS.length) % TASK_FILTERS.length];
83
+ if (next)
84
+ pick(next);
85
+ };
86
+ const open = (task) => {
87
+ if (task)
88
+ go({ type: 'push', route: { name: 'task', ref: task.ref } });
89
+ };
90
+ useKeys(Layer.screen, (input, key) => {
91
+ if (nav.handleKey(input, key))
92
+ return true;
93
+ if (key.ctrl || key.meta)
94
+ return false;
95
+ // Archived is not a tab: moving right from it lands on the first tab, left on the last.
96
+ const tabIndex = TASK_FILTERS.indexOf(filter);
97
+ if ((key.tab && !key.shift) || input === 'l' || key.rightArrow)
98
+ setFilterAt(tabIndex + 1);
99
+ else if ((key.tab && key.shift) || input === 'h' || key.leftArrow)
100
+ setFilterAt(tabIndex === -1 ? TASK_FILTERS.length - 1 : tabIndex - 1);
101
+ else if (/^[1-7]$/.test(input))
102
+ setFilterAt(Number(input) - 1);
103
+ else if (key.return)
104
+ open(selected);
105
+ else if (input === 's' && selected)
106
+ actions.status(selected);
107
+ else if (input === 'a' && selected)
108
+ void actions.assign(selected);
109
+ else if (input === 'x' && selected)
110
+ void actions.toggleDone(selected);
111
+ else
112
+ return false;
113
+ return true;
114
+ }, active);
115
+ const counts = countStatuses(list);
116
+ const compactTabs = width < 96;
117
+ const title = project.data ? `${project.data.name} · ${project.data.key}` : HEADING[filter];
118
+ return (_jsxs(ScreenFrame, { title: title, aside: summaryText(filter, counts, list.length), hints: [
119
+ ['↑↓', 'move'],
120
+ ['enter', 'open'],
121
+ ['n', 'new'],
122
+ ['s', 'status'],
123
+ ['x', 'done'],
124
+ ['←→', 'filter'],
125
+ ['/', 'search'],
126
+ [':', 'commands'],
127
+ ['?', 'help'],
128
+ ], children: [project.data ? _jsx(ProjectLine, { project: project.data }) : null, _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Tabs, { items: TASK_FILTERS.map((value, index) => ({ value, label: TAB_LABELS[value], shortcut: String(index + 1) })), value: TASK_FILTERS.includes(filter) ? filter : null, onChange: pick, active: active, compact: compactTabs }), filter === 'archived' ? _jsx(Text, { color: palette.warning, children: 'archived tasks' }) : null] }), _jsx(Box, { marginTop: 1, flexDirection: "column", height: tableHeight, children: tasks.error ? (_jsx(EmptyState, { lines: [tasks.error.message, tasks.error.hint ?? ''], icon: symbols.cross })) : list.length === 0 && !tasks.loading ? (_jsx(EmptyState, { lines: EMPTY_TASKS[filter], icon: symbols.check })) : (_jsx(TaskTable, { tasks: list, selected: nav.index, offset: nav.offset, height: tableHeight, width: width, showAssignee: filter !== 'mine', pullRequests: pullRequests.data, onSelect: (index) => nav.select(index), onOpen: (index) => open(list[index]), onScroll: (delta) => nav.select(nav.index + delta), active: active })) }), withPreview && selected ? _jsx(TaskPreview, { task: selected, pr: pullRequests.data?.get(selected.id), width: width, remark: summaryRemark(counts) }) : null, showMascot ? _jsx(Mascot, { scene: sceneForTaskList(list, filter, syncStatus?.syncing ?? false), paused: !active }) : null] }));
129
+ }
130
+ function summaryText(filter, counts, total) {
131
+ if (total === 0)
132
+ return undefined;
133
+ if (filter === 'done' || filter === 'archived')
134
+ return `${total} ${filter}`;
135
+ return `${activeCount(counts)} active ${symbols.dot} ${counts.in_progress} in progress ${symbols.dot} ${counts.review} review ${symbols.dot} ${counts.blocked} blocked`;
136
+ }
137
+ /** The selected task at a glance, so most of the time you do not need to open it. */
138
+ function TaskPreview({ task, pr, width, remark }) {
139
+ const description = task.description ? clampLines(wrapText(task.description, width - 4), 2) : [];
140
+ const meta = [
141
+ TYPE_LABELS[task.type],
142
+ task.project?.name,
143
+ task.assignee ? `@${task.assignee.username}` : 'unassigned',
144
+ task.requester ? `for ${task.requester}` : null,
145
+ `updated ${formatRelative(task.updatedAt)}`,
146
+ ].filter(Boolean);
147
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Panel, { title: task.ref, aside: remark ?? undefined, height: PREVIEW_ROWS - 1, flexGrow: 1, children: [_jsx(Text, { wrap: "truncate-end", color: palette.text, bold: true, children: task.title }), _jsxs(Box, { gap: 2, children: [_jsx(StatusBadge, { status: task.status }), _jsx(PriorityMeter, { priority: task.priority }), _jsx(Text, { color: palette.muted, wrap: "truncate-end", children: meta.join(` ${symbols.dot} `) })] }), pr ? (_jsx(PullRequestSummary, { pr: pr })) : task.branch ? (_jsx(Text, { color: palette.muted, wrap: "truncate-end", children: `${symbols.link} ${task.branch}` })) : (_jsx(Text, { color: palette.faint, children: 'no branch yet · b in the task starts one' })), description.length ? (description.map((line, index) => (_jsx(Text, { color: palette.muted, children: line }, index)))) : (_jsx(Text, { color: palette.faint, children: 'No description.' }))] }) }));
148
+ }
149
+ function ProjectLine({ project }) {
150
+ const location = project.repositoryPath ?? project.repositoryUrl;
151
+ return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: palette.muted, wrap: "truncate-end", children: [project.description, `${project.active} active`, `${project.counts.done} done`, location].filter(Boolean).join(` ${symbols.dot} `) }) }));
152
+ }
@@ -0,0 +1,193 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useEffect, useState } from 'react';
4
+ import { PullRequestSummary } from '../components/PullRequestLabel.js';
5
+ import { isClosed, isProvisional, TYPE_LABELS } from '../../domain/task.js';
6
+ import { formatRelative, formatStamp } from '../../utils/time.js';
7
+ import { clampLines, wrapText } from '../../utils/text.js';
8
+ import { useAppState } from '../app-state.js';
9
+ import { EmptyState } from '../components/EmptyState.js';
10
+ import { PriorityMeter, StatusBadge } from '../components/Labels.js';
11
+ import { Badge } from '../kit/Badge.js';
12
+ import { Clickable } from '../kit/Clickable.js';
13
+ import { Panel } from '../kit/Panel.js';
14
+ import { Spinner } from '../kit/Spinner.js';
15
+ import { useLayout } from '../hooks/use-layout.js';
16
+ import { useQuery } from '../hooks/use-query.js';
17
+ import { useTaskActions } from '../hooks/use-task-actions.js';
18
+ import { Layer } from '../input/dispatcher.js';
19
+ import { useKeys } from '../input/KeyProvider.js';
20
+ import { palette, symbols } from '../theme/theme.js';
21
+ import { ScreenFrame } from './ScreenFrame.js';
22
+ import { Mascot } from '../mascot/Mascot.js';
23
+ import { sceneForTask } from '../mascot/selection.js';
24
+ const LABEL_WIDTH = 14;
25
+ export function TaskScreen({ active, taskRef }) {
26
+ const { services, session, cwd, detectMerges, followPullRequests, openOverlay, run, syncStatus } = useAppState();
27
+ useEffect(() => {
28
+ void detectMerges(taskRef);
29
+ void followPullRequests(taskRef);
30
+ }, [detectMerges, followPullRequests, taskRef]);
31
+ const { width, height } = useLayout();
32
+ const actions = useTaskActions();
33
+ const [scroll, setScroll] = useState(0);
34
+ const [taskTarget, setTaskTarget] = useState(null);
35
+ const taskTopic = `task:${session.workspace.id}:${taskRef}`;
36
+ const query = useQuery(() => services.tasks.get(session, taskTarget ?? taskRef), `${taskTopic}:${taskTarget?.id ?? ''}`, [taskTopic]);
37
+ if (query.data && query.data.id !== taskTarget?.id) {
38
+ setTaskTarget({ id: query.data.id, number: query.data.number });
39
+ }
40
+ const task = query.data;
41
+ // Remote mode: conflicts and rejections the last syncs reported for this task.
42
+ const notices = useQuery(async () => (services.sync && query.data ? services.sync.notices(session.workspace.id, query.data.id) : []), `notices:${taskRef}:${query.data?.id ?? ''}`, [taskTopic]);
43
+ const openNotices = () => {
44
+ const sync = services.sync;
45
+ const list = notices.data ?? [];
46
+ if (!sync || list.length === 0 || !task)
47
+ return;
48
+ openOverlay({
49
+ kind: 'picker',
50
+ title: 'Sync notices',
51
+ context: task.ref,
52
+ options: list.flatMap((notice) => [
53
+ ...(notice.kind === 'conflict' && notice.field
54
+ ? [{ value: `restore:${notice.id}`, label: `Restore ${notice.field} to ${JSON.stringify(notice.overwritten)}`, hint: 'their value' }]
55
+ : []),
56
+ { value: `dismiss:${notice.id}`, label: `Dismiss: ${notice.message}`, dim: true },
57
+ ]),
58
+ onSelect: (value) => {
59
+ const [action, id] = value.split(':');
60
+ const notice = list.find((candidate) => candidate.id === id);
61
+ if (!notice)
62
+ return;
63
+ return run(async () => {
64
+ if (action === 'restore' && notice.field) {
65
+ await services.tasks.update(session, task, { [notice.field]: notice.overwritten });
66
+ }
67
+ await sync.dismissNotice(notice.id);
68
+ }, action === 'restore' ? `${task.ref}: ${notice.field} restored` : 'Notice dismissed');
69
+ },
70
+ });
71
+ };
72
+ // Remote mode: the latest chat messages that name this task.
73
+ const chatMentions = useQuery(async () => (services.chat && query.data && !isProvisional(query.data.number) ? services.chat.mentionsOfTask(session, query.data.number, 3) : []), `task-chat:${taskRef}:${query.data?.number ?? ''}`, [taskTopic, `chat:${session.workspace.id}`]);
74
+ const mentionList = chatMentions.data ?? [];
75
+ // The pull request comes from GitHub (gh), so it loads on its own too.
76
+ const pullRequest = useQuery(async () => (query.data?.branch ? services.git.pullRequest(session, taskRef, cwd) : null), `pr:${session.workspace.id}:${taskRef}:${query.data?.branch ?? ''}`);
77
+ // Git state loads on its own so the task shows instantly even in big repositories.
78
+ const git = useQuery(() => services.git.inspect(session, taskRef, cwd), `git:${session.workspace.id}:${taskRef}`);
79
+ // Layout: header, title, chips and notices; then Details + Description; the Activity panel gets the rest.
80
+ // Details and Description side by side only when both keep a useful width.
81
+ const wide = width >= 110;
82
+ const showMascot = width >= 80 && height >= 34 && Boolean(task);
83
+ const mascotRows = showMascot ? 15 : 0;
84
+ const titleLines = task ? clampLines(wrapText(task.title, width), 2) : [''];
85
+ const noticeLines = Math.min(2, notices.data?.length ?? 0);
86
+ const detailsHeight = 8;
87
+ // Title, chips (with a gap) and notices; then the details panel (with a gap).
88
+ const headRows = titleLines.length + 2 + noticeLines + 1 + detailsHeight;
89
+ // Stacked (narrow) layouts give the description what is left above the activity's minimum (2 rows + borders);
90
+ // with no room it is hidden (d still edits it).
91
+ const stackedDescription = Math.min(5, height - headRows - 4 - mascotRows);
92
+ const descriptionHeight = wide ? detailsHeight : stackedDescription >= 3 ? stackedDescription : 0;
93
+ const upperRows = headRows + (wide ? 0 : descriptionHeight);
94
+ const timelineRows = Math.max(2, height - upperRows - 2 - mascotRows);
95
+ const showMentions = wide && mentionList.length > 0;
96
+ const activityWidth = showMentions ? Math.floor(width * 0.62) - 4 : width - 4;
97
+ const timeline = task ? timelineLines(task.timeline, activityWidth) : [];
98
+ const actorWidth = Math.min(18, Math.max(8, ...timeline.map((line) => line.actor.length)) + 2);
99
+ const maxScroll = Math.max(0, timeline.length - timelineRows);
100
+ const offset = Math.min(scroll, maxScroll);
101
+ const scrollBy = (delta) => setScroll(Math.max(0, Math.min(maxScroll, offset + delta)));
102
+ useKeys(Layer.screen, (input, key) => {
103
+ if (!task || key.ctrl || key.meta)
104
+ return false;
105
+ const bindings = {
106
+ s: () => actions.status(task),
107
+ p: () => actions.priority(task),
108
+ a: () => actions.assign(task),
109
+ t: () => actions.type(task),
110
+ m: () => actions.project(task),
111
+ r: () => actions.requester(task),
112
+ d: () => actions.description(task),
113
+ c: () => actions.comment(task),
114
+ e: () => actions.edit(task),
115
+ x: () => actions.toggleDone(task),
116
+ '!': openNotices,
117
+ b: () => actions.branch(task),
118
+ g: () => actions.gitMenu(task),
119
+ C: () => actions.commit(task),
120
+ };
121
+ const action = bindings[input];
122
+ if (action)
123
+ void action();
124
+ // Timeline scrolls from the newest entries (bottom) upwards.
125
+ else if (input === 'k' || key.upArrow)
126
+ setScroll(Math.min(maxScroll, offset + 1));
127
+ else if (input === 'j' || key.downArrow)
128
+ setScroll(Math.max(0, offset - 1));
129
+ else
130
+ return false;
131
+ return true;
132
+ }, active);
133
+ const noticeCount = notices.data?.length ?? 0;
134
+ const hints = [
135
+ ...(noticeCount ? [['!', `${noticeCount} sync notice${noticeCount === 1 ? '' : 's'}`]] : []),
136
+ ['s', 'status'],
137
+ ['p', 'priority'],
138
+ ['a', 'assign'],
139
+ ['c', 'comment'],
140
+ ['e', 'edit'],
141
+ ['g', 'git'],
142
+ ['x', task?.status === 'done' ? 'reopen' : 'done'],
143
+ ['esc', 'back'],
144
+ ];
145
+ if (!task) {
146
+ return (_jsxs(ScreenFrame, { title: taskRef, hints: [['esc', 'back']], children: [query.error ? _jsx(EmptyState, { lines: [query.error.message, query.error.hint ?? ''], icon: symbols.cross }) : _jsx(Spinner, { label: "Loading\u2026" }), width >= 80 && height >= 20 ? _jsx(Mascot, { scene: syncStatus?.syncing ? 'syncing' : query.loading ? 'clockIn' : 'bugHunt', paused: !active }) : null] }));
147
+ }
148
+ const end = timeline.length - offset;
149
+ const visibleTimeline = timeline.slice(Math.max(0, end - timelineRows), end);
150
+ const description = task.description ? clampLines(wrapText(task.description, (wide ? width - Math.floor(width / 2) : width) - 5), Math.max(1, descriptionHeight - 2)) : [];
151
+ const detailsWidth = wide ? Math.floor(width / 2) : width;
152
+ return (_jsxs(ScreenFrame, { title: `${task.ref} · ${TYPE_LABELS[task.type]}`, aside: width >= 70 ? `created ${formatStamp(task.createdAt)}${task.creator ? ` by @${task.creator.username}` : ''} ${symbols.dot} updated ${formatRelative(task.updatedAt)}` : undefined, hints: hints, children: [titleLines.map((line, index) => (_jsx(Text, { bold: true, color: palette.text, children: line }, index))), _jsxs(Box, { gap: 2, marginTop: 1, children: [_jsx(Clickable, { onClick: () => actions.status(task), active: active, children: _jsx(StatusBadge, { status: task.status }) }), _jsx(Clickable, { onClick: () => actions.priority(task), active: active, children: _jsx(PriorityMeter, { priority: task.priority }) }), task.archivedAt ? _jsx(Badge, { tone: "warning", children: `archived ${formatRelative(task.archivedAt)} · e to restore` }) : null, isClosed(task.status) && task.completedAt ? _jsx(Text, { color: palette.muted, children: `closed ${formatRelative(task.completedAt)}` }) : null] }), (notices.data ?? []).slice(0, 2).map((notice) => (_jsx(Clickable, { onClick: openNotices, active: active, children: _jsx(Text, { color: notice.kind === 'conflict' ? palette.warning : palette.danger, wrap: "truncate-end", children: `! ${notice.message}` }) }, notice.id))), _jsxs(Box, { marginTop: 1, flexDirection: wide ? 'row' : 'column', gap: wide ? 1 : 0, children: [_jsxs(Panel, { title: "Details", width: detailsWidth, height: detailsHeight, children: [_jsx(Field, { label: "Project", value: task.project?.name, onClick: () => actions.project(task), active: active }), _jsx(Field, { label: "Assignee", value: task.assignee ? `@${task.assignee.username}` : undefined, hint: task.assignee?.displayName, onClick: () => actions.assign(task), active: active }), _jsx(Field, { label: "Requested by", value: task.requester ?? undefined, onClick: () => actions.requester(task), active: active }), _jsx(GitFields, { state: git.data, fallbackBranch: task.branch ?? task.suggestedBranch, recorded: task.branch !== null, closed: isClosed(task.status), onBranch: () => actions.branch(task), onCommits: () => openOverlay({ kind: 'git-log' }), active: active }), _jsx(PullRequestField, { state: task.branch ? pullRequest.data : null, loading: pullRequest.loading, onClick: () => actions.gitMenu(task), active: active })] }), descriptionHeight ? (_jsx(Panel, { title: "Description", aside: description.length ? 'd edit' : undefined, height: descriptionHeight, flexGrow: 1, children: _jsx(Clickable, { onClick: () => void actions.description(task), active: active, flexDirection: "column", children: description.length ? (description.map((line, index) => (_jsx(Text, { color: palette.text, children: line }, index)))) : (_jsx(Text, { color: palette.faint, children: "No description. Press d (or click) to write one." })) }) })) : null] }), _jsxs(Box, { flexGrow: 1, gap: 1, children: [_jsx(Panel, { title: "Activity", aside: maxScroll > 0 ? (offset > 0 ? '↓ newer' : '↑ older') : undefined, height: timelineRows + 2, flexGrow: 1, children: _jsxs(Clickable, { onWheel: (direction) => scrollBy(-direction), active: active, flexDirection: "column", children: [visibleTimeline.map((line) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.faint, children: line.stamp.padEnd(7) }), _jsx(Text, { color: line.actor ? palette.muted : palette.faint, children: line.actor.padEnd(actorWidth) }), line.kind === 'comment' ? _jsx(Text, { color: palette.accent, children: line.first ? `${symbols.comment} ` : ' ' }) : _jsx(Text, { color: palette.faint, children: '• ' }), _jsx(Text, { color: line.kind === 'event' ? palette.muted : palette.text, children: line.text })] }, line.key))), visibleTimeline.length === 0 ? _jsx(Text, { color: palette.faint, children: "Nothing yet." }) : null] }) }), showMentions ? (_jsx(Panel, { title: "Mentioned in chat", height: timelineRows + 2, width: Math.floor(width * 0.38), children: mentionList.map((message) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.muted, children: `${message.author ? `@${message.author.username}` : 'someone'} ` }), _jsx(Text, { color: palette.faint, children: formatStamp(message.createdAt) })] }), _jsx(Text, { color: palette.text, wrap: "truncate-end", children: message.body.split('\n')[0] })] }, message.id))) })) : null] }), showMascot && task ? _jsx(Mascot, { scene: syncStatus?.syncing ? 'syncing' : sceneForTask(task), paused: !active }) : null] }));
153
+ }
154
+ function PullRequestField({ state, loading, onClick, active }) {
155
+ return (_jsxs(FieldRow, { onClick: onClick, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Pull request" }) }), state?.status === 'found' ? (_jsx(PullRequestSummary, { pr: state.pr })) : state?.status === 'unavailable' ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `${state.reason}${state.hint ? ` ${state.hint}` : ''}` })) : state?.status === 'none' ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `none · g to open one` })) : (loading && state === undefined ? _jsx(Spinner, { label: "checking GitHub\u2026", color: palette.faint }) : _jsx(Text, { color: palette.faint, children: "\u2014" }))] }));
156
+ }
157
+ /** A row of the Details panel: clicking it opens what changes it. */
158
+ function FieldRow({ onClick, active, children }) {
159
+ return (_jsx(Clickable, { onClick: onClick, active: active, children: _jsx(Box, { flexGrow: 1, children: children }) }));
160
+ }
161
+ function Field({ label, value, hint, onClick, active = true, }) {
162
+ return (_jsxs(FieldRow, { onClick: onClick, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: label }) }), _jsxs(Text, { wrap: "truncate-end", children: [value ? _jsx(Text, { color: palette.text, children: value }) : _jsx(Text, { color: palette.faint, children: "\u2014" }), hint ? _jsx(Text, { color: palette.muted, children: value ? ` ${hint}` : ` suggested ${hint}` }) : null] })] }));
163
+ }
164
+ /** Branch and commits rows. Two rows always, so the layout does not jump while Git loads. */
165
+ function GitFields({ state, fallbackBranch, recorded, closed, onBranch, onCommits, active, }) {
166
+ if (!state) {
167
+ return (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Branch", value: recorded ? fallbackBranch : undefined, hint: recorded ? undefined : fallbackBranch, onClick: onBranch, active: active }), _jsxs(FieldRow, { active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: "Commits" }) }), _jsx(Spinner, { label: "reading Git\u2026", color: palette.faint })] })] }));
168
+ }
169
+ if (state.status === 'unavailable') {
170
+ return (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Branch", value: recorded ? fallbackBranch : undefined, hint: recorded ? undefined : fallbackBranch, onClick: onBranch, active: active }), _jsxs(FieldRow, { onClick: onBranch, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: "Git" }) }), _jsx(Text, { color: palette.faint, wrap: "truncate-end", children: state.reason })] })] }));
171
+ }
172
+ const [latest] = state.commits;
173
+ return (_jsxs(_Fragment, { children: [_jsxs(FieldRow, { onClick: onBranch, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: "Branch" }) }), _jsxs(Text, { wrap: "truncate-end", children: [state.checkedOut ? (_jsx(Text, { color: palette.success, children: `${symbols.active} checked out ` })) : state.branchExists ? (_jsx(Text, { color: palette.muted, children: 'not checked out ' })) : state.recorded && !closed ? (_jsx(Text, { color: palette.warning, children: 'deleted, no merge found · b recreate · g forget ' })) : state.recorded ? (_jsx(Text, { color: palette.muted, children: 'deleted ' })) : closed ? null : (_jsx(Text, { color: palette.muted, children: 'suggested · b to start ' })), state.uncommitted > 0 ? _jsx(Text, { color: palette.warning, children: `${state.uncommitted} uncommitted ` }) : null, _jsx(Text, { color: state.branchExists ? palette.text : palette.faint, children: closed && !state.recorded && !state.branchExists ? '—' : state.branch })] })] }), _jsxs(FieldRow, { onClick: onCommits, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: "Commits" }) }), latest ? (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.warning, children: latest.shortHash }), _jsx(Text, { children: ` ${latest.subject}` }), _jsx(Text, { dimColor: true, children: ` ${formatRelative(latest.date)}${state.commits.length > 1 ? ` +${state.commits.length - 1} more` : ''}` })] })) : (_jsx(Text, { color: palette.faint, children: "none yet" }))] })] }));
174
+ }
175
+ /** Flattens the timeline into display rows; comments wrap to at most three lines. */
176
+ function timelineLines(entries, width) {
177
+ const textWidth = Math.max(10, width - 7 - 18 - 2);
178
+ const now = new Date();
179
+ return entries.flatMap((entry) => {
180
+ const stamp = formatStamp(entry.at, now);
181
+ const actor = entry.actor ? `@${entry.actor.username}` : '';
182
+ if (entry.kind === 'event')
183
+ return [{ key: entry.id, kind: entry.kind, stamp, actor, text: entry.text, first: true }];
184
+ return clampLines(wrapText(entry.body, textWidth), 3).map((text, index) => ({
185
+ key: `${entry.id}:${index}`,
186
+ kind: entry.kind,
187
+ stamp: index === 0 ? stamp : '',
188
+ actor: index === 0 ? actor : '',
189
+ text,
190
+ first: index === 0,
191
+ }));
192
+ });
193
+ }