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,36 @@
1
+ import { useTerminalSize } from './use-terminal-size.js';
2
+ import { APP_SLOGAN, WORDMARK, WORDMARK_WIDTH } from '../branding/brand.js';
3
+ /** Full wordmark and slogan at the left of the footer; narrow terminals stack them. */
4
+ export function footerRowsFor(columns) {
5
+ const inline = columns >= WORDMARK_WIDTH + APP_SLOGAN.length + 4;
6
+ const sloganWidth = inline ? columns - WORDMARK_WIDTH - 4 : columns - 2;
7
+ const brandRows = inline
8
+ ? Math.max(WORDMARK.length, Math.ceil(APP_SLOGAN.length / Math.max(1, sloganWidth)))
9
+ : WORDMARK.length + Math.ceil(APP_SLOGAN.length / Math.max(1, sloganWidth));
10
+ return 1 + FOOTER_BRAND_GAP_ROWS + brandRows;
11
+ }
12
+ /** A quiet row between the screen panel and the footer. */
13
+ export const FOOTER_GAP_ROWS = 1;
14
+ /** A quiet row between footer shortcuts and the brand. */
15
+ export const FOOTER_BRAND_GAP_ROWS = 1;
16
+ /** The screen panel's borders (top and bottom; left and right plus padding). */
17
+ const PANEL_ROWS = 2;
18
+ const PANEL_COLUMNS = 4;
19
+ export const SIDEBAR_WIDTH = 26;
20
+ /** From this width on, the sidebar is shown. */
21
+ export const SIDEBAR_FROM = 110;
22
+ export function layoutFor(columns, rows) {
23
+ const sidebar = columns >= SIDEBAR_FROM ? SIDEBAR_WIDTH : 0;
24
+ const barRows = 1 + FOOTER_GAP_ROWS + footerRowsFor(columns);
25
+ return {
26
+ width: Math.max(20, columns - sidebar - PANEL_COLUMNS),
27
+ height: Math.max(4, rows - barRows - PANEL_ROWS),
28
+ rows,
29
+ columns,
30
+ sidebar,
31
+ };
32
+ }
33
+ export function useLayout() {
34
+ const { columns, rows } = useTerminalSize();
35
+ return layoutFor(columns, rows);
36
+ }
@@ -0,0 +1,28 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react';
2
+ import { clampList, moveInList } from '../input/list-navigation.js';
3
+ /** Selection + scrolling for a list of `count` items shown `height` rows at a time. */
4
+ export function useList(count, height, vim = true, initialIndex = 0, onSelect) {
5
+ const onSelectRef = useRef(onSelect);
6
+ useLayoutEffect(() => {
7
+ onSelectRef.current = onSelect;
8
+ }, [onSelect]);
9
+ const [state, setState] = useState(() => clampList({ index: initialIndex, offset: 0 }, count, height));
10
+ const current = clampList(state, count, height);
11
+ return {
12
+ index: current.index,
13
+ offset: current.offset,
14
+ handleKey(input, key) {
15
+ const next = moveInList(current, input, key, count, height, vim);
16
+ if (!next)
17
+ return false;
18
+ setState(next);
19
+ onSelectRef.current?.(next.index);
20
+ return true;
21
+ },
22
+ select(index) {
23
+ const next = clampList({ index, offset: current.offset }, count, height);
24
+ setState(next);
25
+ onSelectRef.current?.(next.index);
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,22 @@
1
+ import { useEffect, useState } from 'react';
2
+ /**
3
+ * Width and height of an element after layout. Ink's `useBoxMetrics` re-renders
4
+ * on every commit even when nothing changed (it sets `hasMeasured` again),
5
+ * which loops when a panel re-renders often; this only updates on a change.
6
+ */
7
+ export function useMeasure(ref) {
8
+ const [size, setSize] = useState({ width: 0, height: 0 });
9
+ // After every render on purpose: layout can change without this component's props changing.
10
+ // It only sets state when the size really changed, so it cannot loop.
11
+ // eslint-disable-next-line react-hooks/exhaustive-deps
12
+ useEffect(() => {
13
+ const layout = ref.current?.yogaNode?.getComputedLayout();
14
+ if (!layout)
15
+ return;
16
+ const width = Math.floor(layout.width);
17
+ const height = Math.floor(layout.height);
18
+ if (width !== size.width || height !== size.height)
19
+ setSize({ width, height });
20
+ });
21
+ return size;
22
+ }
@@ -0,0 +1,29 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { toDisplayError } from '../../utils/errors.js';
3
+ import { useAppState } from '../app-state.js';
4
+ /**
5
+ * Loads data through a service and reloads when `key` or the global data
6
+ * revision changes. Previous data stays on screen while reloading, so
7
+ * nothing flickers after a mutation.
8
+ */
9
+ export function useQuery(load, key, topics = [key]) {
10
+ const { revision, queryVersion } = useAppState();
11
+ const requestKey = `${key}#${revision}#${queryVersion(topics)}`;
12
+ const [loaded, setLoaded] = useState({ key: '', data: undefined, error: null });
13
+ useEffect(() => {
14
+ let cancelled = false;
15
+ load().then((data) => {
16
+ if (!cancelled)
17
+ setLoaded({ key: requestKey, data, error: null });
18
+ }, (error) => {
19
+ if (!cancelled)
20
+ setLoaded({ key: requestKey, data: undefined, error: toDisplayError(error) });
21
+ });
22
+ return () => {
23
+ cancelled = true;
24
+ };
25
+ // `load` is recreated every render; `requestKey` describes what it loads.
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
27
+ }, [requestKey]);
28
+ return { data: loaded.data, error: loaded.error, loading: loaded.key !== requestKey };
29
+ }
@@ -0,0 +1,450 @@
1
+ import { pullRequestWarnings } from '../../application/services/index.js';
2
+ import { isClosed, PRIORITY_LABELS, STATUS_LABELS, TYPE_LABELS, } from '../../domain/task.js';
3
+ import { toDisplayError } from '../../utils/errors.js';
4
+ import { editInEditor } from '../../utils/editor.js';
5
+ import { tildify } from '../../utils/text.js';
6
+ import { useAppState } from '../app-state.js';
7
+ import { useExternalTerminal } from '../input/KeyProvider.js';
8
+ import { fromOption, memberOptions, NONE, priorityOptions, projectOptions, statusOptions, typeOptions } from '../overlays/options.js';
9
+ /**
10
+ * Every edit you can make to a task, as overlay flows. Shared by the task
11
+ * list and the task detail so both behave identically.
12
+ */
13
+ export function useTaskActions() {
14
+ const { services, session, openOverlay, run, notify, cwd, route, go } = useAppState();
15
+ const external = useExternalTerminal();
16
+ const { tasks } = services;
17
+ const report = (error) => {
18
+ const display = toDisplayError(error);
19
+ notify(display.message, 'error', display.hint);
20
+ };
21
+ /** A yes/no window with buttons; Cancel is the default so Enter never destroys anything by accident. */
22
+ const confirm = (title, message, yes, onYes) => openOverlay({
23
+ kind: 'confirm',
24
+ title,
25
+ message,
26
+ confirmLabel: yes,
27
+ // Anything that deletes is marked as dangerous; the rest only asks for a look.
28
+ tone: /delete|force|anyway/i.test(yes) ? 'danger' : 'warning',
29
+ onConfirm: () => onYes(),
30
+ });
31
+ const update = (task, changes, success) => run(() => tasks.update(session, task, changes), `${task.ref} ${success}`);
32
+ const actions = {
33
+ status(task) {
34
+ openOverlay({
35
+ kind: 'picker',
36
+ title: 'Status',
37
+ context: task.ref,
38
+ options: statusOptions,
39
+ initial: task.status,
40
+ onSelect: (value) => update(task, { status: value }, `moved to ${STATUS_LABELS[value]}`),
41
+ });
42
+ },
43
+ priority(task) {
44
+ openOverlay({
45
+ kind: 'picker',
46
+ title: 'Priority',
47
+ context: task.ref,
48
+ options: priorityOptions,
49
+ initial: task.priority,
50
+ onSelect: (value) => update(task, { priority: value }, `priority ${PRIORITY_LABELS[value]}`),
51
+ });
52
+ },
53
+ type(task) {
54
+ openOverlay({
55
+ kind: 'picker',
56
+ title: 'Type',
57
+ context: task.ref,
58
+ options: typeOptions,
59
+ initial: task.type,
60
+ onSelect: (value) => update(task, { type: value }, `is now a ${TYPE_LABELS[value]}`),
61
+ });
62
+ },
63
+ async assign(task) {
64
+ const members = await services.workspaces.members(session);
65
+ openOverlay({
66
+ kind: 'picker',
67
+ title: 'Assign',
68
+ context: task.ref,
69
+ options: memberOptions(members, session.user.id),
70
+ initial: task.assigneeId ?? NONE,
71
+ filterable: true,
72
+ onSelect: (value) => {
73
+ const member = members.find((candidate) => candidate.id === value);
74
+ return update(task, { assigneeId: fromOption(value) }, member ? `assigned to @${member.username}` : 'unassigned');
75
+ },
76
+ create: {
77
+ label: (query) => `Add developer @${query.toLowerCase()} and assign`,
78
+ onCreate: (query) => run(async () => {
79
+ const displayName = query.charAt(0).toUpperCase() + query.slice(1);
80
+ const user = await services.workspaces.addMember(session, { username: query, displayName });
81
+ await tasks.update(session, task, { assigneeId: user.id });
82
+ }, `${task.ref} assigned to @${query.toLowerCase()}`),
83
+ },
84
+ });
85
+ },
86
+ async project(task) {
87
+ const projects = await services.projects.list(session);
88
+ openOverlay({
89
+ kind: 'picker',
90
+ title: 'Project',
91
+ context: task.ref,
92
+ options: projectOptions(projects),
93
+ initial: task.projectId ?? NONE,
94
+ filterable: true,
95
+ onSelect: (value) => {
96
+ const project = projects.find((candidate) => candidate.id === value);
97
+ return update(task, { projectId: fromOption(value) }, project ? `moved to ${project.name}` : 'has no project');
98
+ },
99
+ });
100
+ },
101
+ async requester(task) {
102
+ const suggestions = await tasks.knownRequesters(session);
103
+ openOverlay({
104
+ kind: 'prompt',
105
+ title: 'Requested by',
106
+ context: task.ref,
107
+ initial: task.requester ?? '',
108
+ placeholder: suggestions[0] ?? 'Marketing, Finance, Admissions…',
109
+ suggestions,
110
+ allowEmpty: true,
111
+ onSubmit: (value) => update(task, { requester: value }, value.trim() ? `requested by ${value.trim()}` : 'has no requester'),
112
+ });
113
+ },
114
+ title(task) {
115
+ openOverlay({
116
+ kind: 'prompt',
117
+ title: 'Title',
118
+ context: task.ref,
119
+ initial: task.title,
120
+ onSubmit: (value) => update(task, { title: value }, 'renamed'),
121
+ });
122
+ },
123
+ /** Multi-line descriptions belong in a real editor: SOJA steps aside while $EDITOR runs. */
124
+ async description(task) {
125
+ let edited = null;
126
+ try {
127
+ await external(async () => {
128
+ edited = await editInEditor(task.description ?? '', { name: `${task.ref}.md` });
129
+ });
130
+ }
131
+ catch (error) {
132
+ report(error);
133
+ return;
134
+ }
135
+ const text = edited;
136
+ if (text === null)
137
+ notify('The editor closed without saving. Nothing changed.');
138
+ else if (text === (task.description ?? ''))
139
+ notify('Description unchanged.');
140
+ else
141
+ await update(task, { description: text }, 'description saved');
142
+ },
143
+ descriptionInline(task) {
144
+ openOverlay({
145
+ kind: 'prompt',
146
+ title: 'Description',
147
+ context: task.ref,
148
+ initial: task.description ?? '',
149
+ placeholder: 'What is going on, where, and how to reproduce it',
150
+ allowEmpty: true,
151
+ onSubmit: (value) => update(task, { description: value }, 'description saved'),
152
+ });
153
+ },
154
+ branchName(task) {
155
+ openOverlay({
156
+ kind: 'prompt',
157
+ title: 'Branch',
158
+ context: `${task.ref} · recorded name, Git is not touched`,
159
+ initial: task.branch ?? '',
160
+ placeholder: 'fix/SOJA-12-short-name',
161
+ allowEmpty: true,
162
+ onSubmit: (value) => update(task, { branch: value }, value.trim() ? `branch ${value.trim()}` : 'branch cleared'),
163
+ });
164
+ },
165
+ comment(task) {
166
+ openOverlay({
167
+ kind: 'prompt',
168
+ title: 'Comment',
169
+ context: task.ref,
170
+ placeholder: 'Found it. The webhook retries on 500s…',
171
+ onSubmit: (value) => run(() => tasks.comment(session, task, value), `Comment added to ${task.ref}`),
172
+ });
173
+ },
174
+ /** `soja start` from the TUI: shows what will happen in Git, then does it. */
175
+ async branch(task) {
176
+ let plan;
177
+ try {
178
+ plan = await services.git.plan(session, task, cwd);
179
+ }
180
+ catch (error) {
181
+ report(error);
182
+ return;
183
+ }
184
+ const { branch, root } = plan;
185
+ const gitStep = plan.checkedOut
186
+ ? `Stay on ${branch}`
187
+ : plan.branchExists
188
+ ? `Switch to ${branch}`
189
+ : `Create ${branch} from ${plan.currentBranch ?? 'HEAD'}`;
190
+ const label = plan.linkProject ? `Link repository to ${plan.linkProject.name}, ${gitStep.charAt(0).toLowerCase()}${gitStep.slice(1)}` : gitStep;
191
+ const warning = plan.uncommitted > 0
192
+ ? plan.branchExists && !plan.checkedOut
193
+ ? `${plan.uncommitted} uncommitted: commit or stash first`
194
+ : `${plan.uncommitted} uncommitted change(s) come along`
195
+ : tildify(root);
196
+ openOverlay({
197
+ kind: 'picker',
198
+ title: 'Start on branch',
199
+ context: `${task.ref} · assign to you · In Progress`,
200
+ options: [
201
+ { value: 'start', label, hint: warning },
202
+ { value: 'cancel', label: 'Cancel', dim: true },
203
+ ],
204
+ onSelect: (value) => value === 'start'
205
+ ? run(() => services.git.start(session, task, { cwd, link: plan.linkProject !== null }), `${task.ref} on ${branch}`)
206
+ : undefined,
207
+ });
208
+ },
209
+ /** Choose files, then a message; commits on the task branch with live output. */
210
+ commit(task) {
211
+ openOverlay({ kind: 'commit', task });
212
+ },
213
+ async merge(task) {
214
+ let plan;
215
+ try {
216
+ plan = await services.git.mergePlan(session, task, cwd);
217
+ }
218
+ catch (error) {
219
+ report(error);
220
+ return;
221
+ }
222
+ if (plan.alreadyMerged) {
223
+ notify(`${plan.branch} is already merged into ${plan.into}.`, 'info', 'Delete it from the Git menu (g).');
224
+ return;
225
+ }
226
+ confirm(`Merge into ${plan.into}?`, `${plan.branch} → ${plan.into} · merge commit`, `Merge ${plan.branch} into ${plan.into}`, () => openOverlay({
227
+ kind: 'git-run',
228
+ title: 'Merge',
229
+ context: `${plan.branch} → ${plan.into}`,
230
+ run: async () => {
231
+ const result = await services.git.merge(session, task, { cwd });
232
+ return `Merged into ${result.into} (${result.hash.slice(0, 7)})`;
233
+ },
234
+ next: [
235
+ { key: 'd', label: 'delete branch + mark Done', action: () => actions.deleteBranch(task, { markDone: true }) },
236
+ { key: 'x', label: 'mark Done', action: () => void run(() => tasks.complete(session, task), `${task.ref} done. Nice.`) },
237
+ ],
238
+ recover: {
239
+ merge_conflict: {
240
+ key: 'a',
241
+ label: 'abort merge',
242
+ action: () => actions.abortMerge(task),
243
+ },
244
+ },
245
+ }));
246
+ },
247
+ abortMerge(task) {
248
+ openOverlay({
249
+ kind: 'git-run',
250
+ title: 'Abort merge',
251
+ context: task.ref,
252
+ run: async () => {
253
+ await services.git.abortMerge(session, task, cwd);
254
+ return 'Merge aborted. The repository is back to how it was.';
255
+ },
256
+ });
257
+ },
258
+ /** Confirms, then deletes; an unmerged branch needs a second, explicit confirmation. */
259
+ deleteBranch(task, options = {}) {
260
+ const branch = task.branch;
261
+ if (!branch) {
262
+ notify(`${task.ref} has no branch.`, 'info');
263
+ return;
264
+ }
265
+ const execute = (force) => openOverlay({
266
+ kind: 'git-run',
267
+ title: force ? 'Delete unmerged branch' : 'Delete branch',
268
+ context: branch,
269
+ run: async () => {
270
+ const result = await services.git.deleteBranch(session, task, { cwd, force });
271
+ if (options.markDone)
272
+ await tasks.complete(session, task);
273
+ const switched = result.switchedTo ? ` Switched to ${result.switchedTo}.` : '';
274
+ return `Deleted ${result.branch}.${switched}${options.markDone ? ` ${task.ref} is Done.` : ''}`;
275
+ },
276
+ recover: {
277
+ not_merged: {
278
+ key: 'f',
279
+ label: 'delete anyway…',
280
+ action: () => confirm('Delete an unmerged branch?', `${branch} has commits that are not merged. They will be lost.`, 'Yes, delete it and lose those commits', () => execute(true)),
281
+ },
282
+ },
283
+ });
284
+ const doDelete = () => execute(false);
285
+ if (options.markDone)
286
+ doDelete();
287
+ else
288
+ confirm('Delete branch?', branch, `Delete ${branch}`, doDelete);
289
+ },
290
+ push(task) {
291
+ openOverlay({
292
+ kind: 'git-run',
293
+ title: 'Push',
294
+ context: task.branch ?? task.ref,
295
+ run: async (interactive) => {
296
+ const result = await services.git.push(session, task, { cwd, interactive });
297
+ return `Pushed ${result.branch} to origin`;
298
+ },
299
+ });
300
+ },
301
+ pullRequest(task) {
302
+ confirm('Open a pull request?', `${task.branch ?? task.ref} → ${task.baseBranch ?? 'base branch'} · pushes first`, `Push and open a PR for ${task.ref}`, () => openOverlay({
303
+ kind: 'git-run',
304
+ title: 'Pull request',
305
+ context: task.ref,
306
+ run: async (interactive) => {
307
+ const result = await services.git.openPullRequest(session, task, { cwd, interactive });
308
+ return `Pull request ${result.branch} → ${result.base}: ${result.url}`;
309
+ },
310
+ }));
311
+ },
312
+ /** Merges the task's PR on GitHub after showing what is still pending; optionally deletes the branch. */
313
+ async mergePullRequest(task) {
314
+ const state = await services.git.pullRequest(session, task, cwd);
315
+ if (state.status !== 'found') {
316
+ notify(state.reason, state.status === 'unavailable' ? 'error' : 'info', state.status === 'unavailable' ? state.hint : 'Open one from the Git menu (g).');
317
+ return;
318
+ }
319
+ const { pr } = state;
320
+ if (pr.state !== 'open') {
321
+ notify(`Pull request #${pr.number} is already ${pr.state}.`, 'info');
322
+ return;
323
+ }
324
+ const warnings = pullRequestWarnings(pr);
325
+ const execute = (deleteBranch) => openOverlay({
326
+ kind: 'git-run',
327
+ title: `Merge PR #${pr.number}`,
328
+ context: `${pr.head} → ${pr.base}`,
329
+ run: async () => {
330
+ const result = await services.git.mergePullRequest(session, task, { cwd, deleteBranch });
331
+ return `Merged PR #${result.pr.number} into ${result.pr.base} on GitHub. ${task.ref} is Done.${deleteBranch ? ` ${pr.head} deleted.` : ''}`;
332
+ },
333
+ });
334
+ openOverlay({
335
+ kind: 'picker',
336
+ title: `Merge PR #${pr.number} on GitHub?`,
337
+ context: `${pr.head} → ${pr.base} · merge commit${warnings.length ? ` · ${warnings.join(', ')}` : ''}`,
338
+ options: [
339
+ { value: 'no', label: 'Cancel' },
340
+ { value: 'merge', label: `Merge PR #${pr.number}`, tone: warnings.length ? 'warning' : 'success' },
341
+ { value: 'merge-delete', label: `Merge and delete ${pr.head}`, tone: 'warning' },
342
+ ],
343
+ onSelect: (value) => {
344
+ if (value === 'merge')
345
+ execute(false);
346
+ else if (value === 'merge-delete')
347
+ execute(true);
348
+ },
349
+ });
350
+ },
351
+ /** Everything Git in one menu. */
352
+ gitMenu(task) {
353
+ const items = [
354
+ ['start', 'Start / switch to branch', 'b'],
355
+ ['commit', 'Commit…', 'C'],
356
+ ['push', 'Push branch', ''],
357
+ ['pullRequest', 'Open pull request…', ''],
358
+ ['mergePullRequest', 'Merge pull request on GitHub…', ''],
359
+ ['merge', 'Merge into base…', ''],
360
+ ['abortMerge', 'Abort merge', ''],
361
+ ['deleteBranch', 'Delete branch…', ''],
362
+ ['forget', 'Forget branch (Git untouched)', ''],
363
+ ['log', 'Git log', ''],
364
+ ];
365
+ openOverlay({
366
+ kind: 'picker',
367
+ title: 'Git',
368
+ context: task.branch ? `${task.ref} · ${task.branch}` : `${task.ref} · no branch yet`,
369
+ options: items.map(([value, label, key]) => ({ value, label, hint: key })),
370
+ onSelect: (value) => {
371
+ if (value === 'start')
372
+ void actions.branch(task);
373
+ else if (value === 'commit')
374
+ actions.commit(task);
375
+ else if (value === 'push')
376
+ actions.push(task);
377
+ else if (value === 'pullRequest')
378
+ actions.pullRequest(task);
379
+ else if (value === 'mergePullRequest')
380
+ void actions.mergePullRequest(task);
381
+ else if (value === 'merge')
382
+ void actions.merge(task);
383
+ else if (value === 'abortMerge')
384
+ actions.abortMerge(task);
385
+ else if (value === 'deleteBranch')
386
+ actions.deleteBranch(task);
387
+ else if (value === 'forget')
388
+ void run(() => services.git.forgetBranch(session, task), `${task.ref} has no branch now`);
389
+ else if (value === 'log')
390
+ openOverlay({ kind: 'git-log' });
391
+ },
392
+ });
393
+ },
394
+ toggleDone(task) {
395
+ return isClosed(task.status)
396
+ ? run(() => tasks.reopen(session, task), `${task.ref} reopened`)
397
+ : run(() => tasks.complete(session, task), `${task.ref} done. Nice.`);
398
+ },
399
+ edit(task) {
400
+ const fields = [
401
+ ['title', 'Title', 'e'],
402
+ ['description', 'Description in $EDITOR', 'd'],
403
+ ['descriptionInline', 'Description, one line', ''],
404
+ ['status', 'Status', 's'],
405
+ ['priority', 'Priority', 'p'],
406
+ ['assign', 'Assignee', 'a'],
407
+ ['project', 'Project', 'm'],
408
+ ['type', 'Type', 't'],
409
+ ['requester', 'Requester', 'r'],
410
+ ['branchName', 'Branch name', ''],
411
+ ];
412
+ const archived = task.archivedAt !== null;
413
+ openOverlay({
414
+ kind: 'picker',
415
+ title: 'Edit',
416
+ context: task.ref,
417
+ options: [
418
+ ...fields.map(([value, label, key]) => ({ value, label, hint: key })),
419
+ { value: 'archive', label: archived ? 'Restore from the archive' : 'Archive', hint: archived ? '' : 'hide from lists' },
420
+ { value: 'delete', label: 'Delete permanently…', tone: 'danger', hint: 'owners' },
421
+ ],
422
+ onSelect: (value) => {
423
+ if (value === 'archive')
424
+ void actions.toggleArchive(task);
425
+ else if (value === 'delete')
426
+ actions.remove(task);
427
+ else {
428
+ const field = fields.find(([candidate]) => candidate === value)?.[0];
429
+ if (field)
430
+ void actions[field](task);
431
+ }
432
+ },
433
+ });
434
+ },
435
+ toggleArchive(task) {
436
+ return task.archivedAt
437
+ ? run(() => tasks.restore(session, task), `${task.ref} is back in the lists`)
438
+ : run(() => tasks.archive(session, task), `${task.ref} archived. Find it with / or in Archived tasks.`);
439
+ },
440
+ /** Two confirmations: deleting takes the comments and the timeline with it, for everyone. */
441
+ remove(task) {
442
+ confirm('Delete this task for good?', `${task.ref} · ${task.title}`, `Delete ${task.ref}…`, () => confirm('Really delete it?', 'Its comments and timeline go too, for the whole team. Archive keeps it restorable.', `Yes, delete ${task.ref} permanently`, () => void run(async () => {
443
+ await tasks.remove(session, task);
444
+ if (route.name === 'task')
445
+ go({ type: 'pop' });
446
+ }, `${task.ref} deleted`)));
447
+ },
448
+ };
449
+ return actions;
450
+ }
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useWindowSize } from 'ink';
3
+ import { createContext, useContext } from 'react';
4
+ const SizeContext = createContext(null);
5
+ /**
6
+ * One resize listener for the whole app. Ink's `useWindowSize` adds a
7
+ * listener per component; with dozens of panels that trips Node's
8
+ * "possible EventEmitter leak" warning and re-renders each one separately.
9
+ */
10
+ export function TerminalSizeProvider({ children }) {
11
+ const { columns, rows } = useWindowSize();
12
+ return _jsx(SizeContext.Provider, { value: { columns, rows }, children: children });
13
+ }
14
+ export function useTerminalSize() {
15
+ const size = useContext(SizeContext);
16
+ // Outside the provider (isolated component tests): fall back to Ink's own hook.
17
+ // eslint-disable-next-line react-hooks/rules-of-hooks
18
+ return size ?? useWindowSize();
19
+ }