cohvu 2.2.8 → 2.3.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.
package/dist/tui/App.js CHANGED
@@ -19,9 +19,9 @@ import { TeamTab } from './tabs/TeamTab.js';
19
19
  import { BillingTab } from './tabs/BillingTab.js';
20
20
  import { ProjectTab } from './tabs/ProjectTab.js';
21
21
  import { YouTab } from './tabs/YouTab.js';
22
- import { daysUntil } from './utils.js';
22
+ import { daysUntil, timeUntil } from './utils.js';
23
23
  import { exec, execFile } from 'child_process';
24
- import { existsSync, readFileSync, writeFileSync, mkdirSync, watch } from 'fs';
24
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, watch } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { homedir } from 'os';
27
27
  const STATE_FILE = join(homedir(), '.cohvu', 'state.json');
@@ -72,6 +72,12 @@ export default function App() {
72
72
  execFile(cmd, [url], () => { });
73
73
  }
74
74
  }
75
+ function shouldRequireConsensus(s) {
76
+ if (!s.requireConsensus)
77
+ return false;
78
+ const admins = s.members.filter(m => m.role === 'admin');
79
+ return admins.length >= 2;
80
+ }
75
81
  function copyToClipboard(text) {
76
82
  const cmd = process.platform === 'darwin' ? 'pbcopy'
77
83
  : process.platform === 'win32' ? 'clip' : 'xclip -selection clipboard';
@@ -88,7 +94,7 @@ export default function App() {
88
94
  if (eventType === 'memory') {
89
95
  const event = data;
90
96
  if (event.operation === 'create') {
91
- dispatch({ type: 'ADD_MEMORY', memory: { id: event.id, body: event.body, updated_at: event.updated_at } });
97
+ dispatch({ type: 'ADD_MEMORY', memory: { id: event.id, body: event.body, updated_at: event.updated_at, contributed_by: event.contributed_by, memory_type: event.memory_type } });
92
98
  dispatch({ type: 'SET_LIVE_DOT', memoryId: event.id });
93
99
  if (liveDotTimerRef.current)
94
100
  clearTimeout(liveDotTimerRef.current);
@@ -101,6 +107,9 @@ export default function App() {
101
107
  clearTimeout(liveDotTimerRef.current);
102
108
  liveDotTimerRef.current = setTimeout(() => dispatch({ type: 'CLEAR_LIVE_DOT' }), 10000);
103
109
  }
110
+ else if (event.operation === 'delete') {
111
+ dispatch({ type: 'REMOVE_MEMORY', id: event.id });
112
+ }
104
113
  }
105
114
  else if (eventType === 'role_change') {
106
115
  api.me().then(me => dispatch({ type: 'SET_USER_DATA', me })).catch(() => {
@@ -121,6 +130,14 @@ export default function App() {
121
130
  }
122
131
  });
123
132
  }
133
+ else if (eventType === 'approval') {
134
+ const team = getActiveTeam(stateRef.current);
135
+ if (team) {
136
+ api.listApprovals(team.team_id).then(approvals => {
137
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
138
+ }).catch(() => { });
139
+ }
140
+ }
124
141
  },
125
142
  onConnected: () => {
126
143
  dispatch({ type: 'SET_SSE_CONNECTED', connected: true });
@@ -160,6 +177,8 @@ export default function App() {
160
177
  ]);
161
178
  dispatch({ type: 'SET_MEMBERS', members });
162
179
  dispatch({ type: 'SET_INVITE_LINKS', links });
180
+ const approvals = await api.listApprovals(activeTeam.team_id).catch(() => []);
181
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
163
182
  }
164
183
  else {
165
184
  dispatch({ type: 'SET_MEMBERS', members: [] });
@@ -228,14 +247,12 @@ export default function App() {
228
247
  if (cancelled)
229
248
  return;
230
249
  dispatch({ type: 'SET_USER_DATA', me });
231
- await runSetup();
232
- if (cancelled)
233
- return;
250
+ // Detect platforms without re-running setup (setup already ran in enterDashboard)
234
251
  const platforms = detectPlatformStatuses();
235
252
  dispatch({ type: 'SET_PLATFORMS', platforms });
236
- const projectId = me.user.active_project_id;
237
253
  const flatProjects = deriveFlatProjectsFromMe(me);
238
- const activeProject = flatProjects.find(p => p.project_id === projectId);
254
+ const activeProject = flatProjects.find(p => p.project_id === me.user.active_project_id) ?? flatProjects[0] ?? null;
255
+ const projectId = activeProject?.project_id ?? null;
239
256
  const isTeamProject = activeProject?.owner.kind === 'team';
240
257
  const activeTeamId = isTeamProject && activeProject.owner.kind === 'team' ? activeProject.owner.teamId : null;
241
258
  const activeTeam = activeTeamId ? me.teams.find(t => t.team_id === activeTeamId) : null;
@@ -265,6 +282,16 @@ export default function App() {
265
282
  dispatch({ type: 'SET_BILLING', billing });
266
283
  dispatch({ type: 'SET_INVITE_LINKS', links: inviteLinks });
267
284
  dispatch({ type: 'SET_NOTIFICATIONS', notifications });
285
+ if (isTeamProject && activeTeam) {
286
+ dispatch({ type: 'SET_REQUIRE_CONSENSUS', value: activeTeam.require_consensus ?? false });
287
+ const ssoConfig = await api.getSso(activeTeam.team_id).catch(() => null);
288
+ if (ssoConfig)
289
+ dispatch({ type: 'SET_SSO_CONFIG', config: ssoConfig });
290
+ }
291
+ if (isTeamProject && activeTeam) {
292
+ const approvals = await api.listApprovals(activeTeam.team_id).catch(() => []);
293
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
294
+ }
268
295
  if (notifications.length > 0)
269
296
  api.markNotificationsSeen().catch(() => { });
270
297
  connectFeed(projectId);
@@ -349,8 +376,8 @@ export default function App() {
349
376
  exit();
350
377
  return;
351
378
  }
352
- // Global: q exits (unless modal open or in search mode)
353
- if (input === 'q' && !s.modal && s.knowledgeMode !== 'search') {
379
+ // Global: q exits (unless modal open or in knowledge search/forget mode)
380
+ if (input === 'q' && !s.modal && !(s.tab === 'knowledge' && s.knowledgeMode !== 'browse')) {
354
381
  exit();
355
382
  return;
356
383
  }
@@ -359,17 +386,48 @@ export default function App() {
359
386
  await handleModalKey(input, key);
360
387
  return;
361
388
  }
389
+ // Approval keys (when approvals exist, on team/project tab, team project)
390
+ if ((input === 'a' || input === 'x') && !s.modal && s.pendingApprovals.length > 0 && (s.tab === 'team' || s.tab === 'project')) {
391
+ const activeProject = getActiveProject(s);
392
+ if (activeProject?.owner.kind === 'team') {
393
+ const approval = s.pendingApprovals[0];
394
+ if (input === 'a') {
395
+ dispatch({ type: 'OPEN_MODAL', modal: {
396
+ kind: 'approve-action',
397
+ approvalId: approval.id,
398
+ description: approval.description,
399
+ initiator: approval.initiator_email,
400
+ expiresIn: timeUntil(approval.expires_at),
401
+ } });
402
+ }
403
+ else if (input === 'x') {
404
+ const team = getActiveTeam(s);
405
+ if (team) {
406
+ try {
407
+ await api.cancelApproval(team.team_id, approval.id);
408
+ const approvals = await api.listApprovals(team.team_id);
409
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
410
+ showToast('Canceled', 'success');
411
+ }
412
+ catch {
413
+ showToast('Failed to cancel', 'error');
414
+ }
415
+ }
416
+ }
417
+ return;
418
+ }
419
+ }
362
420
  // Tab switching
363
421
  if (key.tab) {
364
422
  dispatch({ type: 'NEXT_TAB' });
365
- await loadTabData();
423
+ setTimeout(() => loadTabData(), 0);
366
424
  return;
367
425
  }
368
426
  // Number keys switch tabs
369
427
  const tabIdx = parseInt(input, 10);
370
428
  if (tabIdx >= 1 && tabIdx <= 5 && !key.ctrl && !key.meta) {
371
429
  dispatch({ type: 'SWITCH_TAB', tab: TABS[tabIdx - 1] });
372
- await loadTabData();
430
+ setTimeout(() => loadTabData(), 0);
373
431
  return;
374
432
  }
375
433
  // Global 'b' shortcut — subscribe/billing portal from banner
@@ -511,11 +569,12 @@ export default function App() {
511
569
  return;
512
570
  const memberCount = s.members.length;
513
571
  const linkRoles = ['admin', 'member', 'viewer'];
514
- const linkCount = s.userRole === 'admin' ? linkRoles.length : 0;
515
- const totalRows = memberCount + linkCount;
572
+ // Settings rows (admin only): name, consensus, sso, then 3 invite link rows
573
+ const settingsCount = s.userRole === 'admin' ? 6 : 0; // 3 settings + 3 links
574
+ const totalRows = memberCount + settingsCount;
516
575
  const sel = s.teamSelected;
517
- const onLinkRow = s.userRole === 'admin' && sel >= memberCount;
518
576
  const onMemberRow = sel < memberCount;
577
+ const onLinkRow = s.userRole === 'admin' && sel >= memberCount + 3;
519
578
  const team = getActiveTeam(s);
520
579
  if (key.upArrow) {
521
580
  dispatch({ type: 'SET_TEAM_SELECTED', index: Math.max(0, sel - 1) });
@@ -525,8 +584,54 @@ export default function App() {
525
584
  dispatch({ type: 'SET_TEAM_SELECTED', index: Math.min(totalRows - 1, sel + 1) });
526
585
  return;
527
586
  }
587
+ // 'i' key (admin) — open invite modal
588
+ if (input === 'i' && s.userRole === 'admin') {
589
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite', selected: 0 } });
590
+ return;
591
+ }
592
+ // 'r' key on name row — rename team
593
+ if (input === 'r' && s.userRole === 'admin' && sel === memberCount + 0) {
594
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-rename-team', input: '' } });
595
+ return;
596
+ }
597
+ // 'r' key on invite link row — regen link
598
+ if (input === 'r' && s.userRole === 'admin' && onLinkRow) {
599
+ const linkIdx = sel - memberCount - 3;
600
+ const role = linkRoles[linkIdx];
601
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-regen-link', role } });
602
+ return;
603
+ }
604
+ // Enter on consensus row — toggle
605
+ if (key.return && s.userRole === 'admin' && sel === memberCount + 1) {
606
+ if (team) {
607
+ try {
608
+ const newValue = !s.requireConsensus;
609
+ await api.updateTeamSettings(team.team_id, { require_consensus: newValue });
610
+ dispatch({ type: 'SET_REQUIRE_CONSENSUS', value: newValue });
611
+ showToast(newValue ? 'Consensus required' : 'Consensus off', 'success');
612
+ }
613
+ catch {
614
+ showToast('Failed to update', 'error');
615
+ }
616
+ }
617
+ return;
618
+ }
619
+ // 's' key on SSO row — configure or manage SSO
620
+ if (input === 's' && s.userRole === 'admin' && sel === memberCount + 2) {
621
+ if (s.ssoConfig) {
622
+ // SSO already exists — offer edit/delete
623
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'manage-sso' } });
624
+ }
625
+ else {
626
+ dispatch({ type: 'OPEN_MODAL', modal: {
627
+ kind: 'configure-sso', step: 1, issuer: '', clientId: '', clientSecret: '', domains: '', defaultRole: 0, requireSso: false,
628
+ } });
629
+ }
630
+ return;
631
+ }
632
+ // 'c' key on invite link row — copy link
528
633
  if (input === 'c' && s.userRole === 'admin' && onLinkRow) {
529
- const linkIdx = sel - memberCount;
634
+ const linkIdx = sel - memberCount - 3;
530
635
  const role = linkRoles[linkIdx];
531
636
  const link = s.inviteLinks.find(l => l.role === role);
532
637
  if (link) {
@@ -538,12 +643,23 @@ export default function App() {
538
643
  }
539
644
  return;
540
645
  }
541
- if (input === 'r' && s.userRole === 'admin' && onLinkRow) {
542
- const linkIdx = sel - memberCount;
646
+ // 'o' key on invite link row open in browser
647
+ if (input === 'o' && s.userRole === 'admin' && onLinkRow) {
648
+ const linkIdx = sel - memberCount - 3;
543
649
  const role = linkRoles[linkIdx];
544
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-regen-link', role } });
650
+ const link = s.inviteLinks.find(l => l.role === role);
651
+ if (link)
652
+ openBrowser(link.url);
653
+ return;
654
+ }
655
+ // 'd' key (admin) — delete team
656
+ if (input === 'd' && s.userRole === 'admin') {
657
+ if (team) {
658
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-delete-team', slug: team.slug, teamName: team.name, input: '' } });
659
+ }
545
660
  return;
546
661
  }
662
+ // 'e' key on member row — edit role
547
663
  if (input === 'e' && s.userRole === 'admin' && onMemberRow) {
548
664
  const target = s.members[sel];
549
665
  if (target) {
@@ -562,6 +678,7 @@ export default function App() {
562
678
  }
563
679
  return;
564
680
  }
681
+ // 'x' key — remove member or leave
565
682
  if (input === 'x') {
566
683
  if (s.userRole === 'admin' && onMemberRow && team) {
567
684
  const target = s.members[sel];
@@ -634,11 +751,20 @@ export default function App() {
634
751
  // ---- Project keys ----
635
752
  async function handleProjectKey(input, _key) {
636
753
  const s = stateRef.current;
754
+ if (input === 't') {
755
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-team', input: '' } });
756
+ return;
757
+ }
637
758
  if (input === 'r' && s.userRole === 'admin') {
638
759
  dispatch({ type: 'OPEN_MODAL', modal: { kind: 'rename', input: '' } });
639
760
  }
640
761
  else if (input === 'n') {
641
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '' } });
762
+ if (s.teams.length > 0) {
763
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'select-owner', selected: 0 } });
764
+ }
765
+ else {
766
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: null } });
767
+ }
642
768
  }
643
769
  else if (input === 'w' && s.projects.length > 1) {
644
770
  dispatch({ type: 'OPEN_MODAL', modal: { kind: 'switch-project', selected: 0 } });
@@ -740,8 +866,10 @@ export default function App() {
740
866
  modal.kind === 'confirm-regen-link' || modal.kind === 'initiate-consensus' ||
741
867
  modal.kind === 'approve-action') {
742
868
  if (input === 'y') {
869
+ const willExit = modal.kind === 'confirm-logout';
743
870
  await confirmModal(modal);
744
- dispatch({ type: 'CLOSE_MODAL' });
871
+ if (!willExit)
872
+ dispatch({ type: 'CLOSE_MODAL' });
745
873
  }
746
874
  else if (input === 'n') {
747
875
  dispatch({ type: 'CLOSE_MODAL' });
@@ -751,8 +879,9 @@ export default function App() {
751
879
  // Text input modals
752
880
  if ('input' in modal) {
753
881
  if (key.return) {
754
- await confirmModal(modal);
755
- dispatch({ type: 'CLOSE_MODAL' });
882
+ const chained = await confirmModal(modal);
883
+ if (!chained)
884
+ dispatch({ type: 'CLOSE_MODAL' });
756
885
  }
757
886
  else if (key.backspace) {
758
887
  dispatch({ type: 'MODAL_BACKSPACE' });
@@ -776,19 +905,25 @@ export default function App() {
776
905
  else if (key.return) {
777
906
  if (modal.selected === s.projects.length) {
778
907
  dispatch({ type: 'CLOSE_MODAL' });
779
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '' } });
908
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: null } });
780
909
  }
781
910
  else {
782
911
  const project = s.projects[modal.selected];
783
912
  if (project) {
784
- await api.switchProject(project.project_id);
785
- const me = await api.me();
786
- dispatch({ type: 'SET_USER_DATA', me });
787
- dispatch({ type: 'CLOSE_MODAL' });
788
- const newProjectId = me.user.active_project_id;
789
- if (newProjectId)
790
- connectFeed(newProjectId);
791
- await loadTabData();
913
+ try {
914
+ await api.switchProject(project.project_id);
915
+ const me = await api.me();
916
+ dispatch({ type: 'SET_USER_DATA', me });
917
+ dispatch({ type: 'CLOSE_MODAL' });
918
+ const newProjectId = me.user.active_project_id;
919
+ if (newProjectId)
920
+ connectFeed(newProjectId);
921
+ setTimeout(() => loadTabData(), 0);
922
+ }
923
+ catch {
924
+ dispatch({ type: 'CLOSE_MODAL' });
925
+ showToast('Failed to switch project', 'error');
926
+ }
792
927
  }
793
928
  }
794
929
  }
@@ -814,6 +949,23 @@ export default function App() {
814
949
  return;
815
950
  }
816
951
  }
952
+ if (shouldRequireConsensus(stateRef.current) && modal.currentRole === 'admin' && newRole !== 'admin') {
953
+ const team = getActiveTeam(stateRef.current);
954
+ if (team) {
955
+ try {
956
+ await api.initiateApproval(team.team_id, 'demote_admin', `demote ${modal.targetEmail} from admin`, modal.targetUserId);
957
+ const approvals = await api.listApprovals(team.team_id);
958
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
959
+ dispatch({ type: 'CLOSE_MODAL' });
960
+ showToast('Approval requested', 'info');
961
+ }
962
+ catch {
963
+ showToast('Failed to request approval', 'error');
964
+ dispatch({ type: 'CLOSE_MODAL' });
965
+ }
966
+ return;
967
+ }
968
+ }
817
969
  const changeTeam = getActiveTeam(s);
818
970
  if (changeTeam) {
819
971
  try {
@@ -830,14 +982,215 @@ export default function App() {
830
982
  dispatch({ type: 'CLOSE_MODAL' });
831
983
  }
832
984
  }
985
+ // Select owner modal
986
+ if (modal.kind === 'select-owner') {
987
+ const itemCount = 1 + s.teams.length; // "personal" + each team
988
+ if (key.upArrow) {
989
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.max(0, modal.selected - 1) } });
990
+ }
991
+ else if (key.downArrow) {
992
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.min(itemCount - 1, modal.selected + 1) } });
993
+ }
994
+ else if (key.return) {
995
+ dispatch({ type: 'CLOSE_MODAL' });
996
+ if (modal.selected === 0) {
997
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: null } });
998
+ }
999
+ else {
1000
+ const team = s.teams[modal.selected - 1];
1001
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: team.team_id } });
1002
+ }
1003
+ }
1004
+ return;
1005
+ }
1006
+ // Invite modal — select role
1007
+ if (modal.kind === 'invite') {
1008
+ const roles = ['admin', 'member', 'viewer'];
1009
+ if (key.upArrow) {
1010
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.max(0, modal.selected - 1) } });
1011
+ }
1012
+ else if (key.downArrow) {
1013
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.min(2, modal.selected + 1) } });
1014
+ }
1015
+ else if (key.return) {
1016
+ const role = roles[modal.selected];
1017
+ const link = s.inviteLinks.find(l => l.role === role);
1018
+ if (link) {
1019
+ dispatch({ type: 'CLOSE_MODAL' });
1020
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role, url: link.url } });
1021
+ }
1022
+ else {
1023
+ dispatch({ type: 'CLOSE_MODAL' });
1024
+ showToast('Invite link not available', 'error');
1025
+ }
1026
+ }
1027
+ return;
1028
+ }
1029
+ // Invite link modal — copy or open
1030
+ if (modal.kind === 'invite-link') {
1031
+ if (input === 'c') {
1032
+ copyToClipboard(modal.url);
1033
+ dispatch({ type: 'SET_COPIED_FEEDBACK', active: true });
1034
+ if (copiedTimerRef.current)
1035
+ clearTimeout(copiedTimerRef.current);
1036
+ copiedTimerRef.current = setTimeout(() => dispatch({ type: 'SET_COPIED_FEEDBACK', active: false }), 1500);
1037
+ }
1038
+ else if (input === 'o') {
1039
+ openBrowser(modal.url);
1040
+ showToast('Opened in browser', 'info');
1041
+ }
1042
+ return;
1043
+ }
1044
+ // Manage existing SSO — edit or delete
1045
+ if (modal.kind === 'manage-sso') {
1046
+ if (input === 'e') {
1047
+ // Edit — open wizard pre-filled with current values
1048
+ const sso = s.ssoConfig;
1049
+ dispatch({ type: 'CLOSE_MODAL' });
1050
+ dispatch({ type: 'OPEN_MODAL', modal: {
1051
+ kind: 'configure-sso', step: 1,
1052
+ issuer: sso?.issuer ?? '', clientId: '', clientSecret: '',
1053
+ domains: sso?.allowed_domains.join(', ') ?? '', defaultRole: ['member', 'viewer', 'admin'].indexOf(sso?.default_role ?? 'member'),
1054
+ requireSso: sso?.require_sso ?? false,
1055
+ } });
1056
+ }
1057
+ else if (input === 'd') {
1058
+ const team = getActiveTeam(s);
1059
+ if (team) {
1060
+ try {
1061
+ await api.deleteSso(team.team_id);
1062
+ dispatch({ type: 'SET_SSO_CONFIG', config: null });
1063
+ dispatch({ type: 'CLOSE_MODAL' });
1064
+ showToast('SSO removed', 'success');
1065
+ }
1066
+ catch {
1067
+ showToast('Failed to remove SSO', 'error');
1068
+ }
1069
+ }
1070
+ }
1071
+ return;
1072
+ }
1073
+ // Configure SSO wizard
1074
+ if (modal.kind === 'configure-sso') {
1075
+ if (key.escape) {
1076
+ dispatch({ type: 'CLOSE_MODAL' });
1077
+ return;
1078
+ }
1079
+ const fieldMap = { 1: 'issuer', 2: 'clientId', 3: 'clientSecret', 4: 'domains' };
1080
+ if (modal.step >= 1 && modal.step <= 4) {
1081
+ const field = fieldMap[modal.step];
1082
+ const current = modal[field];
1083
+ if (key.return && current.length > 0) {
1084
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: modal.step + 1 } });
1085
+ }
1086
+ else if (key.backspace) {
1087
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current.slice(0, -1) } });
1088
+ }
1089
+ else if (input === ' ') {
1090
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current + ' ' } });
1091
+ }
1092
+ else if (input.length === 1 && !key.ctrl && !key.meta) {
1093
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current + input } });
1094
+ }
1095
+ }
1096
+ else if (modal.step === 5) {
1097
+ // Role selection (member=0, viewer=1, admin=2)
1098
+ if (key.upArrow) {
1099
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, defaultRole: Math.max(0, modal.defaultRole - 1) } });
1100
+ }
1101
+ else if (key.downArrow) {
1102
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, defaultRole: Math.min(2, modal.defaultRole + 1) } });
1103
+ }
1104
+ else if (key.return) {
1105
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 6 } });
1106
+ }
1107
+ }
1108
+ else if (modal.step === 6) {
1109
+ // Require SSO toggle
1110
+ if (input === 'y') {
1111
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, requireSso: true, step: 7 } });
1112
+ }
1113
+ else if (input === 'n') {
1114
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, requireSso: false, step: 7 } });
1115
+ }
1116
+ }
1117
+ else if (modal.step === 7) {
1118
+ // Confirm
1119
+ if (key.return) {
1120
+ const team = getActiveTeam(s);
1121
+ if (team) {
1122
+ const roles = ['member', 'viewer', 'admin'];
1123
+ try {
1124
+ dispatch({ type: 'SET_OPERATION', operation: 'Configuring SSO' });
1125
+ const ssoPayload = {
1126
+ issuer: modal.issuer,
1127
+ client_id: modal.clientId,
1128
+ client_secret: modal.clientSecret,
1129
+ allowed_domains: modal.domains.split(',').map((d) => d.trim()).filter(Boolean),
1130
+ default_role: roles[modal.defaultRole],
1131
+ require_sso: modal.requireSso,
1132
+ };
1133
+ if (s.ssoConfig) {
1134
+ await api.updateSso(team.team_id, ssoPayload);
1135
+ }
1136
+ else {
1137
+ await api.configureSso(team.team_id, ssoPayload);
1138
+ }
1139
+ const ssoConfig = await api.getSso(team.team_id);
1140
+ dispatch({ type: 'SET_SSO_CONFIG', config: ssoConfig });
1141
+ dispatch({ type: 'SET_OPERATION', operation: null });
1142
+ showToast('SSO configured', 'success');
1143
+ }
1144
+ catch {
1145
+ dispatch({ type: 'SET_OPERATION', operation: null });
1146
+ showToast('Failed to configure SSO', 'error');
1147
+ }
1148
+ }
1149
+ dispatch({ type: 'CLOSE_MODAL' });
1150
+ }
1151
+ }
1152
+ return;
1153
+ }
833
1154
  }
834
1155
  // ---- Modal confirmation ----
835
1156
  async function confirmModal(modal) {
836
1157
  const s = stateRef.current;
1158
+ // Logout doesn't need a project
1159
+ if (modal.kind === 'confirm-logout') {
1160
+ try {
1161
+ const credentialsFile = join(homedir(), '.cohvu', 'credentials');
1162
+ if (existsSync(credentialsFile))
1163
+ unlinkSync(credentialsFile);
1164
+ }
1165
+ catch { }
1166
+ exit();
1167
+ return false;
1168
+ }
837
1169
  const projectId = s.activeProjectId;
838
1170
  if (!projectId)
839
- return;
1171
+ return false;
840
1172
  switch (modal.kind) {
1173
+ case 'approve-action': {
1174
+ const team = getActiveTeam(s);
1175
+ if (team && 'approvalId' in modal) {
1176
+ try {
1177
+ dispatch({ type: 'SET_OPERATION', operation: 'Approving' });
1178
+ await api.approveAction(team.team_id, modal.approvalId);
1179
+ const approvals = await api.listApprovals(team.team_id);
1180
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1181
+ dispatch({ type: 'SET_OPERATION', operation: null });
1182
+ const me = await api.me();
1183
+ dispatch({ type: 'SET_USER_DATA', me });
1184
+ await loadTabData();
1185
+ showToast('Approved', 'success');
1186
+ }
1187
+ catch {
1188
+ dispatch({ type: 'SET_OPERATION', operation: null });
1189
+ showToast('Failed to approve', 'error');
1190
+ }
1191
+ }
1192
+ break;
1193
+ }
841
1194
  case 'confirm-forget':
842
1195
  try {
843
1196
  dispatch({ type: 'SET_OPERATION', operation: 'Removing memory' });
@@ -855,6 +1208,21 @@ export default function App() {
855
1208
  case 'confirm-clear': {
856
1209
  const project = getActiveProject(s);
857
1210
  if (project && modal.input === project.slug) {
1211
+ if (shouldRequireConsensus(s)) {
1212
+ const team = getActiveTeam(s);
1213
+ if (team) {
1214
+ try {
1215
+ await api.initiateApproval(team.team_id, 'clear_memories', `clear all memories from "${project.slug}"`, projectId);
1216
+ const approvals = await api.listApprovals(team.team_id);
1217
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1218
+ showToast('Approval requested', 'info');
1219
+ }
1220
+ catch {
1221
+ showToast('Failed to request approval', 'error');
1222
+ }
1223
+ break;
1224
+ }
1225
+ }
858
1226
  try {
859
1227
  dispatch({ type: 'SET_OPERATION', operation: 'Clearing memories' });
860
1228
  await api.clearMemories(projectId);
@@ -872,6 +1240,21 @@ export default function App() {
872
1240
  case 'confirm-delete': {
873
1241
  const project = getActiveProject(s);
874
1242
  if (project && modal.input === project.slug) {
1243
+ if (shouldRequireConsensus(s)) {
1244
+ const team = getActiveTeam(s);
1245
+ if (team) {
1246
+ try {
1247
+ await api.initiateApproval(team.team_id, 'delete_project', `delete project "${project.slug}"`, projectId);
1248
+ const approvals = await api.listApprovals(team.team_id);
1249
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1250
+ showToast('Approval requested', 'info');
1251
+ }
1252
+ catch {
1253
+ showToast('Failed to request approval', 'error');
1254
+ }
1255
+ break;
1256
+ }
1257
+ }
875
1258
  try {
876
1259
  dispatch({ type: 'SET_OPERATION', operation: 'Deleting project' });
877
1260
  await api.deleteProject(projectId);
@@ -943,18 +1326,7 @@ export default function App() {
943
1326
  }
944
1327
  break;
945
1328
  }
946
- case 'confirm-logout':
947
- try {
948
- const credentialsFile = join(homedir(), '.cohvu', 'credentials');
949
- if (existsSync(credentialsFile)) {
950
- const { unlinkSync } = await import('fs');
951
- unlinkSync(credentialsFile);
952
- }
953
- }
954
- catch { }
955
- await new Promise(r => setTimeout(r, 500));
956
- exit();
957
- break;
1329
+ // confirm-logout handled above (before projectId check)
958
1330
  case 'rename': {
959
1331
  if (!modal.input)
960
1332
  break;
@@ -979,7 +1351,9 @@ export default function App() {
979
1351
  const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
980
1352
  try {
981
1353
  dispatch({ type: 'SET_OPERATION', operation: 'Creating project' });
982
- const project = await api.createProject(modal.input, slug);
1354
+ const project = modal.teamId
1355
+ ? await api.createTeamProject(modal.teamId, modal.input, slug)
1356
+ : await api.createProject(modal.input, slug);
983
1357
  await api.switchProject(project.id);
984
1358
  const me = await api.me();
985
1359
  dispatch({ type: 'SET_USER_DATA', me });
@@ -993,6 +1367,47 @@ export default function App() {
993
1367
  }
994
1368
  break;
995
1369
  }
1370
+ case 'create-team': {
1371
+ if (!modal.input)
1372
+ break;
1373
+ const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1374
+ try {
1375
+ dispatch({ type: 'SET_OPERATION', operation: 'Creating team' });
1376
+ const team = await api.createTeam(modal.input, slug);
1377
+ dispatch({ type: 'CLOSE_MODAL' });
1378
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-team-project', teamId: team.id, teamName: modal.input, input: '' } });
1379
+ dispatch({ type: 'SET_OPERATION', operation: null });
1380
+ return true; // chained to create-team-project — caller must not CLOSE_MODAL
1381
+ }
1382
+ catch {
1383
+ dispatch({ type: 'SET_OPERATION', operation: null });
1384
+ showToast('Failed to create team', 'error');
1385
+ }
1386
+ break;
1387
+ }
1388
+ case 'create-team-project': {
1389
+ if (!modal.input)
1390
+ break;
1391
+ const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1392
+ try {
1393
+ dispatch({ type: 'SET_OPERATION', operation: 'Creating project' });
1394
+ const project = await api.createTeamProject(modal.teamId, modal.input, slug);
1395
+ await api.switchProject(project.id);
1396
+ const me = await api.me();
1397
+ dispatch({ type: 'SET_USER_DATA', me });
1398
+ dispatch({ type: 'SET_OPERATION', operation: null });
1399
+ const newProjectId = me.user.active_project_id;
1400
+ if (newProjectId)
1401
+ connectFeed(newProjectId);
1402
+ await loadTabData();
1403
+ showToast('Team created', 'success');
1404
+ }
1405
+ catch {
1406
+ dispatch({ type: 'SET_OPERATION', operation: null });
1407
+ showToast('Failed to create project', 'error');
1408
+ }
1409
+ break;
1410
+ }
996
1411
  case 'confirm-regen-link': {
997
1412
  const teamForRegen = getActiveTeam(s);
998
1413
  if (teamForRegen) {
@@ -1011,16 +1426,81 @@ export default function App() {
1011
1426
  }
1012
1427
  break;
1013
1428
  }
1429
+ case 'confirm-rename-team': {
1430
+ if (!modal.input)
1431
+ break;
1432
+ const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1433
+ const teamForRename = getActiveTeam(s);
1434
+ if (teamForRename) {
1435
+ try {
1436
+ dispatch({ type: 'SET_OPERATION', operation: 'Renaming team' });
1437
+ await api.renameTeam(teamForRename.team_id, modal.input, slug);
1438
+ const me = await api.me();
1439
+ dispatch({ type: 'SET_USER_DATA', me });
1440
+ dispatch({ type: 'SET_OPERATION', operation: null });
1441
+ showToast('Team renamed', 'success');
1442
+ }
1443
+ catch {
1444
+ dispatch({ type: 'SET_OPERATION', operation: null });
1445
+ showToast('Failed to rename team', 'error');
1446
+ }
1447
+ }
1448
+ break;
1449
+ }
1450
+ case 'confirm-delete-team': {
1451
+ const teamForDelete = getActiveTeam(s);
1452
+ if (teamForDelete && modal.input === modal.slug) {
1453
+ if (shouldRequireConsensus(s)) {
1454
+ const team = getActiveTeam(s);
1455
+ if (team) {
1456
+ try {
1457
+ await api.initiateApproval(team.team_id, 'delete_team', `delete team "${team.name}"`);
1458
+ const approvals = await api.listApprovals(team.team_id);
1459
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1460
+ showToast('Approval requested', 'info');
1461
+ }
1462
+ catch {
1463
+ showToast('Failed to request approval', 'error');
1464
+ }
1465
+ break;
1466
+ }
1467
+ }
1468
+ try {
1469
+ dispatch({ type: 'SET_OPERATION', operation: 'Deleting team' });
1470
+ await api.deleteTeam(teamForDelete.team_id);
1471
+ const me = await api.me();
1472
+ dispatch({ type: 'SET_USER_DATA', me });
1473
+ dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
1474
+ dispatch({ type: 'SET_OPERATION', operation: null });
1475
+ const newProjectId = me.user.active_project_id;
1476
+ if (newProjectId) {
1477
+ connectFeed(newProjectId);
1478
+ await loadTabData();
1479
+ }
1480
+ else {
1481
+ dispatch({ type: 'SET_MEMORIES', memories: [], total: 0 });
1482
+ dispatch({ type: 'SET_MEMBERS', members: [] });
1483
+ }
1484
+ showToast('Team deleted', 'success');
1485
+ }
1486
+ catch {
1487
+ dispatch({ type: 'SET_OPERATION', operation: null });
1488
+ showToast('Failed to delete team', 'error');
1489
+ }
1490
+ }
1491
+ break;
1492
+ }
1014
1493
  }
1494
+ return false;
1015
1495
  }
1016
1496
  // ---- Small terminal guard ----
1017
- if (cols < 60 || rows < 20) {
1497
+ if (cols < 80 || rows < 20) {
1018
1498
  return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Box, { height: 1 }), _jsx(Text, { color: "gray", children: " terminal too small" }), _jsx(Text, { color: "gray", dimColor: true, children: " resize to continue" })] }));
1019
1499
  }
1020
1500
  // ---- Determine content height ----
1021
1501
  // Header + divider + (banner + divider?) + tabbar + divider = top
1022
1502
  // toast? + operation? + divider + footer = bottom
1023
- const hasBanners = state.notifications.some(n => !n.seen) || hasBillingBanner(state) || state.firstLogin || !!state.joinedProjectName;
1503
+ const hasBanners = state.notifications.some(n => !n.seen) || hasBillingBanner(state) || state.firstLogin || !!state.joinedProjectName || state.pendingApprovals.some(a => new Date(a.expires_at) > new Date());
1024
1504
  const topLines = 2 + (hasBanners ? 2 : 0) + 2; // header+div, (banner+div), tabbar+div
1025
1505
  const bottomLines = (state.toast ? 1 : 0) + (state.operationPending ? 1 : 0) + 2; // div + footer
1026
1506
  const contentHeight = Math.max(1, rows - topLines - bottomLines);