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,450 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { taskRefsIn } from '../../domain/chat.js';
5
+ import { formatTaskRef } from '../../domain/task.js';
6
+ import { formatStamp } from '../../utils/time.js';
7
+ import { terminalSafe, wrapText } from '../../utils/text.js';
8
+ import { useAppState } from '../app-state.js';
9
+ import { EmptyState } from '../components/EmptyState.js';
10
+ import { useLayout } from '../hooks/use-layout.js';
11
+ import { useQuery } from '../hooks/use-query.js';
12
+ import { Layer } from '../input/dispatcher.js';
13
+ import { useKeys } from '../input/KeyProvider.js';
14
+ import { editText } from '../input/text-editing.js';
15
+ import { palette, symbols, toneColors } from '../theme/theme.js';
16
+ import { Clickable } from '../kit/Clickable.js';
17
+ import { Spinner } from '../kit/Spinner.js';
18
+ import { ScreenFrame } from './ScreenFrame.js';
19
+ /** Channel list beside the messages from this width on. */
20
+ const WIDE = 100;
21
+ const SIDEBAR = 24;
22
+ const COMPOSER_LINES = 4;
23
+ const TOPIC = '__topic__';
24
+ const ARCHIVE = '__archive__';
25
+ const EMPTY_DRAFT = { value: '', cursor: 0 };
26
+ export function ChatScreen({ active, initialChannel }) {
27
+ const { services } = useAppState();
28
+ const chat = services.chat;
29
+ if (!chat) {
30
+ return (_jsx(ScreenFrame, { hints: [['esc', 'back']], children: _jsx(EmptyState, { lines: ['The chat needs a team.', 'Sign in to a SOJA server with `soja login --server <url>`.'] }) }));
31
+ }
32
+ return _jsx(Chat, { chat: chat, active: active, initialChannel: initialChannel });
33
+ }
34
+ function Chat({ chat, active, initialChannel }) {
35
+ const { services, session, run, notify, invalidateQueries, openOverlay, go, syncStatus } = useAppState();
36
+ const { width, height, sidebar: layoutSidebar } = useLayout();
37
+ const [channelId, setChannelId] = useState(null);
38
+ const [focus, setFocus] = useState('input');
39
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
40
+ const [replyTo, setReplyTo] = useState(null);
41
+ const [editing, setEditing] = useState(null);
42
+ const [selected, setSelected] = useState(0);
43
+ const channels = useQuery(() => chat.channels(session), `chat-channels:${session.workspace.id}`, [`chat-channels:${session.workspace.id}`]);
44
+ const list = channels.data ?? [];
45
+ const wanted = initialChannel?.replace(/^#/, '').toLowerCase();
46
+ const channel = list.find((candidate) => candidate.id === channelId) ?? list.find((candidate) => candidate.name === wanted) ?? list[0] ?? null;
47
+ const messages = useQuery(async () => (channel ? chat.messages(session, channel.id, 300) : []), `chat-messages:${channel?.id ?? ''}`, channel ? [`chat-messages:${channel.id}`] : []);
48
+ const items = messages.data ?? [];
49
+ const members = useQuery(() => services.workspaces.members(session), `members:${session.workspace.id}`, [`members:${session.workspace.id}`]);
50
+ const notices = useQuery(async () => ((await services.sync?.notices(session.workspace.id)) ?? []).filter((notice) => notice.field === 'chat'), `chat-notices:${session.workspace.id}`, [`chat-notices:${session.workspace.id}`]);
51
+ // Reading a channel marks it read (once per new message, not on every refresh).
52
+ const marked = useRef(new Map());
53
+ const latestSeq = Math.max(0, ...items.map((message) => message.seq ?? 0));
54
+ useEffect(() => {
55
+ if (!active || !channel || latestSeq <= channel.lastReadSeq || (marked.current.get(channel.id) ?? 0) >= latestSeq)
56
+ return;
57
+ marked.current.set(channel.id, latestSeq);
58
+ void chat.markRead(session, channel.id).then(() => invalidateQueries([
59
+ `chat-channels:${session.workspace.id}`,
60
+ `sidebar-channels:${session.workspace.id}`,
61
+ `chat-totals:${session.workspace.id}`,
62
+ ]), () => undefined);
63
+ }, [active, channel, latestSeq, chat, session, invalidateQueries]);
64
+ const current = items[Math.min(selected, items.length - 1)];
65
+ const switchTo = (next) => {
66
+ setChannelId(next.id);
67
+ setReplyTo(null);
68
+ setEditing(null);
69
+ setFocus('input');
70
+ };
71
+ const cycle = (step) => {
72
+ const open = list.filter((candidate) => !candidate.archivedAt);
73
+ const index = open.findIndex((candidate) => candidate.id === channel?.id);
74
+ const next = open[(index + step + open.length) % open.length];
75
+ if (next)
76
+ switchTo(next);
77
+ };
78
+ const pickChannel = () => openOverlay({
79
+ kind: 'picker',
80
+ title: 'Channels',
81
+ filterable: true,
82
+ initial: channel?.id ?? null,
83
+ options: [
84
+ ...list.map((candidate) => ({
85
+ value: candidate.id,
86
+ label: `#${candidate.name}`,
87
+ ...(candidate.unread ? { hint: `${candidate.unread} unread${candidate.mentions ? ` ${symbols.dot} @${candidate.mentions}` : ''}` } : {}),
88
+ ...(candidate.archivedAt ? { dim: true, hint: 'archived' } : {}),
89
+ })),
90
+ ...(channel
91
+ ? [
92
+ { value: TOPIC, label: `Topic of #${channel.name}…`, hint: channel.topic ?? 'none' },
93
+ ...(channel.name === 'general'
94
+ ? []
95
+ : [{ value: ARCHIVE, label: channel.archivedAt ? `Restore #${channel.name}` : `Archive #${channel.name}`, hint: 'owners', dim: true }]),
96
+ ]
97
+ : []),
98
+ ],
99
+ onSelect: (value) => {
100
+ if (value === TOPIC && channel)
101
+ return editTopic(channel);
102
+ if (value === ARCHIVE && channel) {
103
+ const archived = !channel.archivedAt;
104
+ return run(() => chat.updateChannel(session, channel.id, { archived }), archived ? `#${channel.name} archived` : `#${channel.name} restored`);
105
+ }
106
+ const next = list.find((candidate) => candidate.id === value);
107
+ if (next)
108
+ switchTo(next);
109
+ },
110
+ create: {
111
+ label: (query) => `Create #${query.trim().toLowerCase().replace(/^#/, '')}`,
112
+ onCreate: (query) => run(() => chat.createChannel(session, query).then((created) => setChannelId(created.id)), 'Channel created'),
113
+ },
114
+ });
115
+ const editTopic = (target) => openOverlay({
116
+ kind: 'prompt',
117
+ title: `Topic of #${target.name}`,
118
+ initial: target.topic ?? '',
119
+ placeholder: 'What this channel is for',
120
+ allowEmpty: true,
121
+ onSubmit: (topic) => run(() => chat.updateChannel(session, target.id, { topic: topic.trim() || null }), 'Topic saved'),
122
+ });
123
+ const reviewNotices = () => {
124
+ const sync = services.sync;
125
+ const pending = notices.data ?? [];
126
+ if (!sync || pending.length === 0)
127
+ return;
128
+ openOverlay({
129
+ kind: 'picker',
130
+ title: 'Not applied',
131
+ context: 'Chat',
132
+ options: pending.flatMap((notice) => [
133
+ ...(typeof notice.overwritten === 'string' ? [{ value: `restore:${notice.id}`, label: `Put the text back: ${oneLine(notice.overwritten)}` }] : []),
134
+ { value: `dismiss:${notice.id}`, label: `Dismiss: ${notice.message}`, dim: true },
135
+ ]),
136
+ onSelect: (value) => {
137
+ const [action, id] = value.split(':');
138
+ const notice = pending.find((candidate) => candidate.id === id);
139
+ if (!notice)
140
+ return;
141
+ if (action === 'restore' && typeof notice.overwritten === 'string') {
142
+ setDraft({ value: notice.overwritten, cursor: notice.overwritten.length });
143
+ setFocus('input');
144
+ }
145
+ return run(() => sync.dismissNotice(notice.id));
146
+ },
147
+ });
148
+ };
149
+ const submit = () => {
150
+ const text = draft.value.trim();
151
+ if (!text || !channel)
152
+ return;
153
+ const target = editing;
154
+ const quoted = replyTo;
155
+ setDraft(EMPTY_DRAFT);
156
+ setReplyTo(null);
157
+ setEditing(null);
158
+ void run(() => (target ? chat.edit(session, target.id, text) : chat.send(session, channel.id, text, { replyToId: quoted?.id ?? null }))).then((ok) => {
159
+ // Keep what you wrote if it could not be queued.
160
+ if (!ok)
161
+ setDraft({ value: text, cursor: text.length });
162
+ });
163
+ };
164
+ /** `@an` + tab → `@angel `. Returns false when there is nothing to complete. */
165
+ const complete = () => {
166
+ const before = draft.value.slice(0, draft.cursor);
167
+ const match = /@([\w.-]*)$/.exec(before);
168
+ if (!match)
169
+ return false;
170
+ const prefix = (match[1] ?? '').toLowerCase();
171
+ const names = (members.data ?? []).map((member) => member.username).filter((name) => name !== session.user.username && name.toLowerCase().startsWith(prefix));
172
+ const [name] = names;
173
+ if (!name)
174
+ return true; // nothing matches; stay in the field
175
+ const start = before.length - prefix.length;
176
+ const value = `${draft.value.slice(0, start)}${name} ${draft.value.slice(draft.cursor)}`;
177
+ setDraft({ value, cursor: start + name.length + 1 });
178
+ return true;
179
+ };
180
+ const focusMessages = () => {
181
+ if (items.length === 0)
182
+ return;
183
+ setSelected(items.length - 1);
184
+ setFocus('messages');
185
+ };
186
+ useKeys(Layer.input, (input, key) => {
187
+ if (key.return && key.meta) {
188
+ setDraft(insert(draft, '\n'));
189
+ return true;
190
+ }
191
+ if (key.return) {
192
+ submit();
193
+ return true;
194
+ }
195
+ if (key.tab) {
196
+ if (!complete())
197
+ focusMessages();
198
+ return true;
199
+ }
200
+ if (key.escape && (replyTo || editing)) {
201
+ if (editing)
202
+ setDraft(EMPTY_DRAFT);
203
+ setReplyTo(null);
204
+ setEditing(null);
205
+ return true;
206
+ }
207
+ if (key.upArrow && !draft.value) {
208
+ focusMessages();
209
+ return true;
210
+ }
211
+ // A multi-line paste keeps its lines.
212
+ if (input.length > 1 && /[\r\n]/.test(input) && !key.ctrl && !key.meta) {
213
+ setDraft(insert(draft, terminalSafe(input)));
214
+ return true;
215
+ }
216
+ const next = editText(draft, input, key);
217
+ if (!next)
218
+ return false;
219
+ setDraft(next);
220
+ return true;
221
+ }, active && focus === 'input');
222
+ const openTask = (message) => {
223
+ const refs = taskRefsIn(message.body).map(formatTaskRef);
224
+ const [only] = refs;
225
+ if (!only)
226
+ notify('No task in this message.');
227
+ else if (refs.length === 1)
228
+ go({ type: 'push', route: { name: 'task', ref: only } });
229
+ else {
230
+ openOverlay({
231
+ kind: 'picker',
232
+ title: 'Open task',
233
+ options: refs.map((ref) => ({ value: ref, label: ref })),
234
+ onSelect: (ref) => go({ type: 'push', route: { name: 'task', ref } }),
235
+ });
236
+ }
237
+ };
238
+ useKeys(Layer.screen, (input, key) => {
239
+ // Ctrl+G is available while the composer is focused; TextInput leaves it
240
+ // unhandled, so the channel picker works without changing focus first.
241
+ if (key.ctrl && input.toLowerCase() === 'g') {
242
+ pickChannel();
243
+ return true;
244
+ }
245
+ if (key.ctrl || key.meta)
246
+ return false;
247
+ if (focus === 'input')
248
+ return false;
249
+ const message = current;
250
+ if (key.tab || input === 'i' || key.escape)
251
+ setFocus('input');
252
+ else if (input === 'j' || key.downArrow) {
253
+ if (selected >= items.length - 1)
254
+ setFocus('input');
255
+ else
256
+ setSelected(selected + 1);
257
+ }
258
+ else if (input === 'k' || key.upArrow) {
259
+ if (selected > 0)
260
+ setSelected(selected - 1);
261
+ else if (channel) {
262
+ void run(async () => {
263
+ const count = await chat.loadOlder(session, channel.id);
264
+ setSelected(count);
265
+ if (count === 0)
266
+ notify(`That is the beginning of #${channel.name}.`);
267
+ });
268
+ }
269
+ }
270
+ else if (input === '#')
271
+ pickChannel();
272
+ else if (input === ']')
273
+ cycle(1);
274
+ else if (input === '[')
275
+ cycle(-1);
276
+ else if (input === '!')
277
+ reviewNotices();
278
+ else if (!message || message.deletedAt)
279
+ return false;
280
+ else if (input === 'r') {
281
+ setReplyTo(message);
282
+ setEditing(null);
283
+ setFocus('input');
284
+ }
285
+ else if (input === 'e') {
286
+ if (!message.mine)
287
+ notify('You can only edit your own messages.', 'error');
288
+ else {
289
+ setEditing(message);
290
+ setReplyTo(null);
291
+ setDraft({ value: message.body, cursor: message.body.length });
292
+ setFocus('input');
293
+ }
294
+ }
295
+ else if (input === 'd') {
296
+ if (!message.mine)
297
+ notify('You can only delete your own messages.', 'error');
298
+ else {
299
+ openOverlay({
300
+ kind: 'confirm',
301
+ title: 'Delete this message?',
302
+ message: `“${oneLine(message.body)}” is removed for everyone in the channel.`,
303
+ confirmLabel: 'Delete for everyone',
304
+ tone: 'danger',
305
+ onConfirm: () => run(() => chat.remove(session, message.id), 'Message deleted'),
306
+ });
307
+ }
308
+ }
309
+ else if (input === 't') {
310
+ let ref = '';
311
+ void run(async () => {
312
+ ref = (await chat.taskFromMessage(session, message.id)).ref;
313
+ }).then((ok) => {
314
+ if (ok)
315
+ notify(`Created ${ref} from the message`, 'success');
316
+ });
317
+ }
318
+ else if (key.return)
319
+ openTask(message);
320
+ else
321
+ return false;
322
+ return true;
323
+ }, active);
324
+ // ── Layout ───────────────────────────────────────────────────────────────
325
+ // The app sidebar already lists channels; the chat's own list is for when it is hidden.
326
+ const wide = width >= WIDE && layoutSidebar === 0;
327
+ const mainWidth = wide ? width - SIDEBAR - 2 : width;
328
+ const composerLines = composerRows(draft, mainWidth - 2).slice(-COMPOSER_LINES);
329
+ const noticeCount = notices.data?.length ?? 0;
330
+ const banner = editing ? 'Editing your message' : replyTo ? `Replying to ${replyTo.author ? `@${replyTo.author.username}` : 'a message'}: ${oneLine(replyTo.body)}` : null;
331
+ const areaRows = Math.max(3, height - 2 - composerLines.length - (banner ? 1 : 0) - (noticeCount ? 1 : 0));
332
+ const focusMessagesAt = (index) => {
333
+ setSelected(index);
334
+ setFocus('messages');
335
+ };
336
+ const rows = messageRows(items, mainWidth - 2);
337
+ // Pinned to the newest message; scrolls up only as far as the selection needs.
338
+ let start = Math.max(0, rows.length - areaRows);
339
+ const firstSelected = focus === 'messages' ? rows.findIndex((row) => row.index === selected) : -1;
340
+ if (firstSelected >= 0 && firstSelected < start)
341
+ start = firstSelected;
342
+ const visible = rows.slice(start, start + areaRows);
343
+ const hints = focus === 'input'
344
+ ? [
345
+ ['enter', editing ? 'save' : 'send'],
346
+ ['alt+enter', 'new line'],
347
+ ['ctrl+g', 'channels'],
348
+ ['tab', '@ complete / messages'],
349
+ ['esc', replyTo || editing ? 'cancel' : 'back'],
350
+ ]
351
+ : [
352
+ ...(noticeCount ? [['!', `${noticeCount} not applied`]] : []),
353
+ ['r', 'reply'],
354
+ ['t', 'task'],
355
+ ['e', 'edit'],
356
+ ['d', 'delete'],
357
+ ['enter', 'open task'],
358
+ ['#', 'channels'],
359
+ ['tab', 'write'],
360
+ ];
361
+ return (_jsx(ScreenFrame, { title: channel ? `#${channel.name}` : 'Chat', aside: syncStatus?.online === false
362
+ ? 'offline · sent when you reconnect'
363
+ : channel?.archivedAt
364
+ ? 'archived'
365
+ : (channel?.topic ?? (wide ? undefined : '# channels')), hints: hints, children: _jsxs(Box, { children: [wide ? _jsx(Sidebar, { channels: list, current: channel?.id ?? null }) : null, _jsxs(Box, { flexDirection: "column", width: mainWidth, children: [_jsx(Clickable, { onWheel: (direction) => focusMessagesAt(Math.max(0, Math.min(items.length - 1, (focus === 'messages' ? selected : items.length - 1) + direction))), active: active, flexDirection: "column", children: _jsx(Box, { flexDirection: "column", height: areaRows, justifyContent: "flex-end", children: !channel ? (channels.data ? _jsx(EmptyState, { lines: ['No channels yet.', 'They arrive with the first sync.'], icon: symbols.unread }) : _jsx(Spinner, { label: "Loading\u2026" })) : items.length === 0 ? (_jsx(EmptyState, { lines: [`No messages in #${channel.name} yet.`, 'Say hi.'], icon: symbols.unread })) : (visible.map((row) => (_jsx(Clickable, { onClick: () => focusMessagesAt(row.index), active: active, children: _jsx(MessageRow, { row: row, message: items[row.index], selected: focus === 'messages' && row.index === selected }) }, row.key)))) }) }), noticeCount ? (_jsx(Text, { color: palette.danger, wrap: "truncate-end", children: `! ${notices.data?.[0]?.message ?? ''}${noticeCount > 1 ? ` (+${noticeCount - 1})` : ''} ${symbols.dot} tab, then ! to review` })) : null, banner ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `${symbols.reply} ${banner} ${symbols.dot} esc cancel` })) : null, _jsx(Clickable, { onClick: () => setFocus('input'), active: active, children: _jsx(Composer, { lines: composerLines, active: active && focus === 'input', placeholder: channel ? `Message #${channel.name}` : 'Message' }) })] })] }) }));
366
+ }
367
+ function Sidebar({ channels, current }) {
368
+ return (_jsxs(Box, { flexDirection: "column", width: SIDEBAR, marginRight: 2, flexShrink: 0, children: [_jsx(Text, { dimColor: true, bold: true, children: "CHANNELS" }), channels.map((channel) => {
369
+ const here = channel.id === current;
370
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { wrap: "truncate-end", color: here ? palette.accent : undefined, dimColor: Boolean(channel.archivedAt), bold: here || channel.unread > 0, children: `${here ? symbols.pointer : ' '}#${channel.name}` }), channel.unread ? (_jsx(Text, { color: channel.mentions ? palette.warning : palette.accent, children: channel.mentions ? `@${channel.mentions}` : String(channel.unread) })) : null] }, channel.id));
371
+ }), _jsx(Text, { dimColor: true, children: '[ ] switch # all' })] }));
372
+ }
373
+ /** Each author keeps a color, so a conversation reads at a glance. */
374
+ const AUTHOR_TONES = ['info', 'merged', 'warning', 'success', 'danger'];
375
+ function authorColor(message) {
376
+ if (message.mine)
377
+ return palette.accent;
378
+ const name = message.author?.username ?? '';
379
+ let hash = 0;
380
+ for (const char of name)
381
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
382
+ return toneColors(AUTHOR_TONES[hash % AUTHOR_TONES.length] ?? 'info').fg;
383
+ }
384
+ function MessageRow({ row, message, selected }) {
385
+ if (!message)
386
+ return null;
387
+ const background = selected ? palette.selection : message.mentionsMe ? palette.warningSoft : undefined;
388
+ const bg = background ? { backgroundColor: background } : {};
389
+ if (row.kind === 'quote') {
390
+ return (_jsx(Box, { flexGrow: 1, ...bg, children: _jsx(Text, { wrap: "truncate-end", color: palette.faint, children: ` ${' '.repeat(row.stamp.length)}${row.text}` }) }));
391
+ }
392
+ const textColor = message.deletedAt ? palette.faint : palette.text;
393
+ return (_jsx(Box, { flexGrow: 1, ...bg, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.accent, children: selected ? symbols.pointer : ' ' }), _jsx(Text, { color: palette.faint, children: row.stamp }), _jsx(Text, { bold: true, color: authorColor(message), children: row.author }), _jsx(Text, { color: textColor, italic: Boolean(message.deletedAt), children: row.text }), row.kind === 'first' && message.pending ? _jsx(Text, { color: palette.faint, children: ` ${symbols.pending}` }) : null, row.kind === 'first' && message.editedAt && !message.deletedAt ? _jsx(Text, { color: palette.faint, children: ' (edited)' }) : null] }) }));
394
+ }
395
+ function Composer({ lines, active, placeholder }) {
396
+ const empty = lines.length === 1 && lines[0]?.text === '';
397
+ return (_jsx(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: active ? palette.borderFocus : palette.border, paddingX: 1, children: lines.map((line, index) => (_jsxs(Text, { children: [_jsx(Text, { color: active ? palette.accent : undefined, dimColor: !active, children: index === 0 ? '› ' : ' ' }), empty ? (_jsxs(_Fragment, { children: [active ? _jsx(Text, { inverse: true, children: placeholder.slice(0, 1) }) : null, _jsx(Text, { dimColor: true, children: active ? placeholder.slice(1) : placeholder })] })) : line.cursor === null || !active ? (_jsx(Text, { children: line.text })) : (_jsxs(_Fragment, { children: [_jsx(Text, { children: line.text.slice(0, line.cursor) }), _jsx(Text, { inverse: true, children: line.text.slice(line.cursor, line.cursor + 1) || ' ' }), _jsx(Text, { children: line.text.slice(line.cursor + 1) })] }))] }, index))) }));
398
+ }
399
+ /** The draft split into display lines, with the cursor placed on its line. */
400
+ function composerRows(draft, width) {
401
+ const size = Math.max(4, width);
402
+ const rows = [];
403
+ let offset = 0;
404
+ for (const paragraph of draft.value.split('\n')) {
405
+ const chunks = paragraph.length === 0 ? [''] : (paragraph.match(new RegExp(`.{1,${size}}`, 'gs')) ?? ['']);
406
+ for (const [index, chunk] of chunks.entries()) {
407
+ const last = index === chunks.length - 1;
408
+ const inside = draft.cursor >= offset && (draft.cursor < offset + chunk.length || (last && draft.cursor === offset + chunk.length));
409
+ rows.push({ text: chunk, cursor: inside ? draft.cursor - offset : null });
410
+ offset += chunk.length;
411
+ }
412
+ offset += 1; // the newline
413
+ }
414
+ return rows;
415
+ }
416
+ function messageRows(messages, width) {
417
+ const now = new Date();
418
+ const authorWidth = Math.min(16, Math.max(6, ...messages.map((message) => (message.author?.username.length ?? 7) + 2)));
419
+ const stampWidth = 7;
420
+ const textWidth = Math.max(10, width - 1 - stampWidth - authorWidth);
421
+ return messages.flatMap((message, index) => {
422
+ const stamp = formatStamp(message.createdAt, now).padEnd(stampWidth);
423
+ const author = (message.author ? `@${message.author.username}` : 'someone').slice(0, authorWidth - 1).padEnd(authorWidth);
424
+ const rows = [];
425
+ if (message.replyTo) {
426
+ const who = message.replyTo.author ? `@${message.replyTo.author.username}: ` : '';
427
+ const quoted = message.replyTo.deleted ? 'deleted message' : oneLine(message.replyTo.body);
428
+ rows.push({ key: `${message.id}:q`, index, kind: 'quote', stamp, author: '', text: `${symbols.reply} ${who}${quoted}` });
429
+ }
430
+ const lines = message.deletedAt ? ['message deleted'] : wrapText(message.body, textWidth);
431
+ for (const [line, text] of lines.entries()) {
432
+ rows.push({
433
+ key: `${message.id}:${line}`,
434
+ index,
435
+ kind: line === 0 ? 'first' : 'more',
436
+ stamp: line === 0 ? stamp : ' '.repeat(stampWidth),
437
+ author: line === 0 ? author : ' '.repeat(authorWidth),
438
+ text,
439
+ });
440
+ }
441
+ return rows;
442
+ });
443
+ }
444
+ function insert(draft, text) {
445
+ return { value: draft.value.slice(0, draft.cursor) + text + draft.value.slice(draft.cursor), cursor: draft.cursor + text.length };
446
+ }
447
+ function oneLine(text) {
448
+ const [first = ''] = text.split('\n');
449
+ return first.length > 60 ? `${first.slice(0, 59)}${symbols.ellipsis}` : first;
450
+ }
@@ -0,0 +1,99 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { APP_DESCRIPTION, APP_NAME } from '../branding/brand.js';
4
+ import { useLayout } from '../hooks/use-layout.js';
5
+ import { palette } from '../theme/theme.js';
6
+ import { Keycap } from '../kit/Keycap.js';
7
+ import { Panel } from '../kit/Panel.js';
8
+ import { ScreenFrame } from './ScreenFrame.js';
9
+ const SECTIONS = [
10
+ [
11
+ 'Move',
12
+ [
13
+ ['j / k', 'Down / up'],
14
+ ['g / G', 'Top / bottom'],
15
+ ['enter', 'Open'],
16
+ ['esc / q', 'Back'],
17
+ ['h / l', 'Previous / next filter'],
18
+ ['1–7', 'Jump to filter'],
19
+ ],
20
+ ],
21
+ [
22
+ 'Tasks',
23
+ [
24
+ ['n', 'New task'],
25
+ ['s', 'Status'],
26
+ ['p', 'Priority (in a task)'],
27
+ ['a', 'Assign'],
28
+ ['c', 'Comment'],
29
+ ['e', 'Edit any field'],
30
+ ['d', 'Description'],
31
+ ['m', 'Move to project'],
32
+ ['t', 'Type'],
33
+ ['r', 'Requester'],
34
+ ['x', 'Done / reopen'],
35
+ ],
36
+ ],
37
+ [
38
+ 'Git (in a task)',
39
+ [
40
+ ['b', 'Start / switch branch'],
41
+ ['g', 'Git menu'],
42
+ ['C', 'Commit (pick files)'],
43
+ ],
44
+ ],
45
+ [
46
+ 'Chat (remote mode)',
47
+ [
48
+ ['#', 'Open the chat'],
49
+ ['enter / alt+enter', 'Send / new line'],
50
+ ['tab', '@ complete, or pick a message'],
51
+ ['r / t', 'Reply / task from message'],
52
+ ['e / d', 'Edit / delete yours'],
53
+ ['ctrl+g', 'Pick / create channel'],
54
+ ['[ ] / #', 'Switch channel'],
55
+ ],
56
+ ],
57
+ [
58
+ 'Go to',
59
+ [
60
+ ['/', 'Search'],
61
+ [': or ctrl+k', 'Commands'],
62
+ ['p', 'Projects'],
63
+ ['w', 'Workspaces'],
64
+ ['A', 'Access approvals (CEO, remote mode)'],
65
+ ['r', 'Link repo (in Projects)'],
66
+ ],
67
+ ],
68
+ [
69
+ 'Mouse',
70
+ [
71
+ ['click', 'Select; again to open'],
72
+ ['wheel', 'Scroll lists and chat'],
73
+ ['click outside', 'Close a window'],
74
+ ['shift+drag', 'Select text to copy'],
75
+ ],
76
+ ],
77
+ [
78
+ 'General',
79
+ [
80
+ ['?', 'Help'],
81
+ ['q', 'Quit (from home)'],
82
+ ['ctrl+c', 'Quit now'],
83
+ ],
84
+ ],
85
+ ];
86
+ export function HelpScreen() {
87
+ const { width } = useLayout();
88
+ const columns = width >= 118 ? 3 : width >= 76 ? 2 : 1;
89
+ // Sections go to the shortest column, in order, so the columns end at similar heights.
90
+ const groups = Array.from({ length: columns }, () => []);
91
+ const heights = Array.from({ length: columns }, () => 0);
92
+ for (const section of SECTIONS) {
93
+ const shortest = heights.indexOf(Math.min(...heights));
94
+ groups[shortest]?.push(section);
95
+ heights[shortest] = (heights[shortest] ?? 0) + section[1].length + 2;
96
+ }
97
+ const columnWidth = Math.floor((width - (columns - 1)) / columns);
98
+ return (_jsx(ScreenFrame, { title: `${APP_NAME} Help`, aside: APP_DESCRIPTION, hints: [['esc', 'back']], children: _jsx(Box, { gap: 1, children: groups.map((group, index) => (_jsx(Box, { flexDirection: "column", width: columnWidth, children: group.map(([title, keys]) => (_jsx(Panel, { title: title, children: keys.map(([key, description]) => (_jsxs(Box, { children: [_jsx(Box, { width: Math.max(...keys.map(([candidate]) => candidate.length)) + 4, flexShrink: 0, children: _jsx(Keycap, { keys: key }) }), _jsx(Text, { color: palette.muted, wrap: "truncate-end", children: description })] }, key))) }, title))) }, index))) }) }));
99
+ }
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { basename } from 'node:path';
3
+ import { Box, Text } from 'ink';
4
+ import { useAppState } from '../app-state.js';
5
+ import { EmptyState } from '../components/EmptyState.js';
6
+ import { EMPTY_PROJECTS } from '../copy.js';
7
+ import { useFlows } from '../hooks/use-flows.js';
8
+ import { useLayout } from '../hooks/use-layout.js';
9
+ import { useList } from '../hooks/use-list.js';
10
+ import { useQuery } from '../hooks/use-query.js';
11
+ import { Layer } from '../input/dispatcher.js';
12
+ import { useKeys } from '../input/KeyProvider.js';
13
+ import { palette, symbols } from '../theme/theme.js';
14
+ import { Clickable } from '../kit/Clickable.js';
15
+ import { Gauge } from '../kit/Gauge.js';
16
+ import { ScreenFrame } from './ScreenFrame.js';
17
+ export function ProjectsScreen({ active }) {
18
+ const { services, session, go } = useAppState();
19
+ const { width, height } = useLayout();
20
+ const query = useQuery(() => services.projects.list(session), `projects:${session.workspace.id}`, [`projects:${session.workspace.id}`, `tasks:${session.workspace.id}`]);
21
+ const projects = query.data ?? [];
22
+ const rows = Math.max(1, height - 3);
23
+ const list = useList(projects.length, rows);
24
+ const flows = useFlows();
25
+ useKeys(Layer.screen, (input, key) => {
26
+ if (list.handleKey(input, key))
27
+ return true;
28
+ if (key.ctrl || key.meta)
29
+ return false;
30
+ const project = projects[list.index];
31
+ if (key.return && project)
32
+ go({ type: 'push', route: { name: 'project', projectId: project.id } });
33
+ else if (input === 'n')
34
+ flows.newProject();
35
+ else if (input === 'r' && project)
36
+ void flows.pickRepository(project);
37
+ else if (input === 'e' && project)
38
+ flows.editProject(project);
39
+ else
40
+ return false;
41
+ return true;
42
+ }, active);
43
+ const wide = width >= 88;
44
+ // Fixed columns: pointer, key, active; then progress, review, blocked, done gauge and repo when wide.
45
+ const nameWidth = Math.max(10, width - 2 - 9 - 8 - (wide ? 10 + 8 + 9 + 17 + 16 : 0) - 1);
46
+ return (_jsx(ScreenFrame, { title: "Projects", aside: projects.length ? `${projects.length} project${projects.length === 1 ? '' : 's'}` : undefined, hints: [
47
+ ['↑↓', 'move'],
48
+ ['enter', 'open'],
49
+ ['n', 'new project'],
50
+ ['r', 'link repo'],
51
+ ['e', 'edit'],
52
+ ['esc', 'back'],
53
+ ], children: projects.length === 0 && !query.loading ? (_jsx(EmptyState, { lines: EMPTY_PROJECTS, icon: symbols.dot })) : (_jsxs(Clickable, { onWheel: (direction) => list.select(list.index + direction), active: active, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: 2 }), _jsx(Header, { width: 9, text: "KEY" }), _jsx(Box, { width: nameWidth, flexShrink: 0, children: _jsx(Text, { color: palette.faint, bold: true, children: "NAME" }) }), _jsx(Header, { width: 8, text: "ACTIVE", right: true }), wide ? (_jsxs(_Fragment, { children: [_jsx(Header, { width: 10, text: "PROGRESS", right: true }), _jsx(Header, { width: 8, text: "REVIEW", right: true }), _jsx(Header, { width: 9, text: "BLOCKED", right: true }), _jsx(Box, { width: 17, paddingLeft: 3, children: _jsx(Text, { color: palette.faint, bold: true, children: "DONE" }) }), _jsx(Box, { width: 16, paddingLeft: 2, children: _jsx(Text, { color: palette.faint, bold: true, children: "REPO" }) })] })) : null] }), projects.slice(list.offset, list.offset + rows - 1).map((project, position) => {
54
+ const index = list.offset + position;
55
+ const selected = index === list.index;
56
+ const bg = selected ? { backgroundColor: palette.selection } : {};
57
+ const total = Object.values(project.counts).reduce((sum, count) => sum + count, 0);
58
+ return (_jsx(Clickable, { active: active, onClick: () => (selected ? go({ type: 'push', route: { name: 'project', projectId: project.id } }) : list.select(index)), children: _jsxs(Box, { flexGrow: 1, ...bg, children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: palette.accent, children: selected ? symbols.pointer : ' ' }) }), _jsx(Box, { width: 9, children: _jsx(Text, { color: selected ? palette.accent : palette.muted, bold: selected, children: project.key }) }), _jsx(Box, { width: nameWidth, flexShrink: 0, children: _jsx(Text, { color: palette.text, bold: selected, wrap: "truncate-end", children: project.name }) }), _jsx(Count, { width: 8, value: project.active, color: palette.text }), wide ? (_jsxs(_Fragment, { children: [_jsx(Count, { width: 10, value: project.counts.in_progress, color: palette.warning }), _jsx(Count, { width: 8, value: project.counts.review, color: palette.info }), _jsx(Count, { width: 9, value: project.counts.blocked, color: palette.danger }), _jsx(Box, { width: 17, paddingLeft: 3, children: _jsx(Gauge, { value: project.counts.done, total: total, width: 8 }) }), _jsx(Box, { width: 16, paddingLeft: 2, children: _jsx(Text, { color: project.repositoryPath ? palette.muted : palette.faint, wrap: "truncate-end", children: project.repositoryPath ? basename(project.repositoryPath) : 'not linked · r' }) })] })) : null] }) }, project.id));
59
+ })] })) }));
60
+ }
61
+ function Header({ width, text, right = false }) {
62
+ return (_jsx(Box, { width: width, justifyContent: right ? 'flex-end' : 'flex-start', children: _jsx(Text, { color: palette.faint, bold: true, children: text }) }));
63
+ }
64
+ function Count({ width, value, color }) {
65
+ return (_jsx(Box, { width: width, justifyContent: "flex-end", children: _jsx(Text, { color: value ? color : palette.faint, children: value ? String(value) : symbols.dot }) }));
66
+ }
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useHints } from '../chrome/context.js';
3
+ import { useLayout } from '../hooks/use-layout.js';
4
+ import { Panel } from '../kit/Panel.js';
5
+ /**
6
+ * A screen: one panel filling the main area, titled in its border. Its
7
+ * shortcuts go to the status bar while it is on top. Content keeps its
8
+ * natural height and is clipped, never squeezed into overlapping rows.
9
+ */
10
+ export function ScreenFrame({ hints, title, aside, children }) {
11
+ useHints(hints);
12
+ const { height } = useLayout();
13
+ return (_jsx(Panel, { title: title, aside: aside, focused: true, height: height + 2, flexGrow: 1, children: children }));
14
+ }