@elu-labs/elu 0.0.0-stage → 0.1.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +327 -2
  4. package/SECURITY.md +333 -0
  5. package/THIRD_PARTY_NOTICES.md +62 -0
  6. package/bin/elu-code.mjs +209 -0
  7. package/bin/elu.cjs +10 -0
  8. package/bin/start.cjs +6 -0
  9. package/docs/peer-network.md +257 -0
  10. package/docs/permissions.md +285 -0
  11. package/docs/vendors/antigravity.md +131 -0
  12. package/docs/vendors/claude.md +124 -0
  13. package/docs/vendors/codex.md +64 -0
  14. package/docs/vendors/grok.md +151 -0
  15. package/lib/acp-client.mjs +100 -0
  16. package/lib/adapters/agy.mjs +283 -0
  17. package/lib/adapters/claude.mjs +311 -0
  18. package/lib/adapters/codex.mjs +137 -0
  19. package/lib/adapters/grok.mjs +384 -0
  20. package/lib/adapters/index.mjs +13 -0
  21. package/lib/agents.mjs +77 -0
  22. package/lib/agy-hook.mjs +53 -0
  23. package/lib/approval-server.mjs +85 -0
  24. package/lib/approvals.mjs +128 -0
  25. package/lib/awake.mjs +45 -0
  26. package/lib/board.mjs +261 -0
  27. package/lib/bot-models.mjs +71 -0
  28. package/lib/bot-schedules.mjs +291 -0
  29. package/lib/bots.mjs +361 -0
  30. package/lib/changes.mjs +168 -0
  31. package/lib/checks.mjs +102 -0
  32. package/lib/conductor.mjs +337 -0
  33. package/lib/config.mjs +147 -0
  34. package/lib/conformance.mjs +109 -0
  35. package/lib/default-bots.mjs +150 -0
  36. package/lib/detect.mjs +108 -0
  37. package/lib/diff.mjs +217 -0
  38. package/lib/editor.mjs +165 -0
  39. package/lib/format.mjs +51 -0
  40. package/lib/git.mjs +173 -0
  41. package/lib/init.mjs +129 -0
  42. package/lib/install.mjs +130 -0
  43. package/lib/kanban.mjs +180 -0
  44. package/lib/mcp.mjs +79 -0
  45. package/lib/memory.mjs +35 -0
  46. package/lib/models.mjs +144 -0
  47. package/lib/modes.mjs +95 -0
  48. package/lib/never-list.mjs +110 -0
  49. package/lib/never-shims.mjs +311 -0
  50. package/lib/paste.mjs +56 -0
  51. package/lib/peer/a2a-http.mjs +166 -0
  52. package/lib/peer/a2a.mjs +357 -0
  53. package/lib/peer/asks.mjs +329 -0
  54. package/lib/peer/canonical.mjs +72 -0
  55. package/lib/peer/cli.mjs +154 -0
  56. package/lib/peer/daemon.mjs +525 -0
  57. package/lib/peer/identity.mjs +81 -0
  58. package/lib/peer/index.mjs +291 -0
  59. package/lib/peer/intake.mjs +207 -0
  60. package/lib/peer/invite.mjs +76 -0
  61. package/lib/peer/paths.mjs +7 -0
  62. package/lib/peer/peerdata.mjs +21 -0
  63. package/lib/peer/pictures.mjs +295 -0
  64. package/lib/peer/plans.mjs +182 -0
  65. package/lib/peer/relay.mjs +31 -0
  66. package/lib/peer/replies.mjs +128 -0
  67. package/lib/peer/roster.mjs +256 -0
  68. package/lib/peer/rpc.mjs +88 -0
  69. package/lib/peer/secrets.mjs +88 -0
  70. package/lib/peer/settings.mjs +45 -0
  71. package/lib/peer/shares.mjs +120 -0
  72. package/lib/peer/status.mjs +84 -0
  73. package/lib/peer/taskstore.mjs +108 -0
  74. package/lib/peer/transport.mjs +120 -0
  75. package/lib/pictures.mjs +104 -0
  76. package/lib/plan.mjs +238 -0
  77. package/lib/policy.mjs +98 -0
  78. package/lib/process.mjs +92 -0
  79. package/lib/project-settings.mjs +55 -0
  80. package/lib/project-trust.mjs +230 -0
  81. package/lib/projects.mjs +668 -0
  82. package/lib/publish.mjs +105 -0
  83. package/lib/runner.mjs +874 -0
  84. package/lib/sandbox.mjs +102 -0
  85. package/lib/schedules.mjs +270 -0
  86. package/lib/selection.mjs +202 -0
  87. package/lib/shell.mjs +101 -0
  88. package/lib/state.mjs +247 -0
  89. package/lib/terminal-bots.mjs +239 -0
  90. package/lib/terminal-commands.mjs +165 -0
  91. package/lib/terminal-plan.mjs +37 -0
  92. package/lib/terminal-visuals.mjs +268 -0
  93. package/lib/terminal.mjs +3159 -0
  94. package/lib/unit-file.mjs +17 -0
  95. package/lib/usage.mjs +249 -0
  96. package/lib/util.mjs +86 -0
  97. package/lib/vendor-check.mjs +96 -0
  98. package/lib/vendors.mjs +191 -0
  99. package/lib/workspace.mjs +168 -0
  100. package/lib/yaml.mjs +624 -0
  101. package/npm-shrinkwrap.json +827 -0
  102. package/package.json +70 -4
@@ -0,0 +1,3159 @@
1
+ import blessed from 'blessed';
2
+ import { ProjectController, dashboardSnapshot, claimView, registerProject, registeredProjects, archiveProject, taskDetails, taskPatch, conversationEntries, modelLabel, modelName, reportedModels, sessionModel, knownProject, nameProject, savedProjectName } from './projects.mjs';
3
+ import { redact } from './publish.mjs';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { projectPath, projectFolder, createFolderPlan, createFolder, pendingRequests, removeRequest, takeRequest, addToRequest } from './workspace.mjs';
8
+ import { git, currentBranch } from './git.mjs';
9
+ import { runShell, recordShell, shellText } from './shell.mjs';
10
+ import { readProjectSettings, saveInstructions, saveContext } from './project-settings.mjs';
11
+ import { answerTrust, pendingTrust, reviewLines, unusedTrust } from './project-trust.mjs';
12
+ import { parseConfig } from './config.mjs';
13
+ import { enqueue as queueRequest, sendCommand, startConductor as launchConductor, retryRequest, saveConductorSettings, conductorSettings } from './conductor.mjs';
14
+ import { readablePictures } from './pictures.mjs';
15
+ import { boardRows, barCells, barColumns, boardSections, rowActions, rowCard, peerDecision, peerIncoming } from './board.mjs';
16
+ import { COMMANDS, FIRST_CHECK, argumentMatches, commandMatches, mentionMatches, sessionMentions, commandWords, needsArgument, parseCommand, vendorText } from './terminal-commands.mjs';
17
+ import { BOT_COLORS, listBots, getBot, needBot, chatBots, sessionBots, botColor, botOfKey, botShadows, botWorks, pickBotColor, saveBot, canShare } from './bots.mjs';
18
+ import { botModels, matchModels } from './bot-models.mjs';
19
+ import { modelChoices, findModel, choiceName, modelText, modelProblem, rememberModel, installedVendors } from './models.mjs';
20
+ import { addAgent, agentWords } from './agents.mjs';
21
+ import { botLines, botNames, botMessage, botDialogs } from './terminal-bots.mjs';
22
+ import { PALETTE, displayText, ink, keyHints, visible, terminalLayout, relativeTime, remote, peerName, threadWho, clockTime, teamPanes, membersLine, roomTarget, dmTarget, botTarget, peerSummary, chatBlock, messageTime, fitWords, wrapText } from './terminal-visuals.mjs';
23
+ import { sanitize } from './peer/peerdata.mjs';
24
+ import { approveTask, rejectTask } from './plan.mjs';
25
+ import { taskCard, taskNeedsYou } from './terminal-plan.mjs';
26
+ import { COLUMNS, readBoard, addCard, editCard, removeCard, startCard, linkCards, setDone, boardColumns, fitCard, closeSession, reopenSession } from './kanban.mjs';
27
+ import { taskTitle, setTitle, nameProblem, TITLE_MAX } from './state.mjs';
28
+ import { setPermissions as saveMode } from './runner.mjs';
29
+ import { listSchedules, saveSchedule, removeSchedule, pauseSchedule, runScheduleNow, runDue, parseWhen, nextRun, dateText, installScheduler, uninstallScheduler } from './schedules.mjs';
30
+ import { runBotSchedules, botScheduleIn, botScheduleLines, setBotSchedule, runBotNow, botSchedulesOn, botChatSession, botSchedulesShown, botScheduleBrief } from './bot-schedules.mjs';
31
+ import { peerSettings as savedPeerSettings, peerPolicy as savedPeerPolicy } from './peer/settings.mjs';
32
+ import { MODES, LABELS, BADGES, checkMode, checkPlace, checkFolders, capMode, defaultMode, saveDefaultMode, isPeerRun } from './modes.mjs';
33
+ import { VENDORS, VENDOR_NAMES, CAPS } from './vendors.mjs';
34
+ import { pendingApprovals, answerApproval, SUMMARY_LIMIT } from './approvals.mjs';
35
+ import { neverSentence, whyBlocked } from './never-list.mjs';
36
+ import { conformanceState, uncheckedText, vendorReading } from './conformance.mjs';
37
+ import { findBin, hash } from './util.mjs';
38
+ import { shellSandboxLine } from './sandbox.mjs';
39
+ import { changeRows, changeSummary } from './changes.mjs';
40
+ import { usageTab, refreshUsage, usageStale } from './usage.mjs';
41
+ import { detect } from './detect.mjs';
42
+ import { refreshAwake } from './awake.mjs';
43
+ import { editable } from './editor.mjs';
44
+ import { attachPaste, pastedText } from './paste.mjs';
45
+ import { attachReports, attachSelection, systemClipboard, buttonCode } from './selection.mjs';
46
+
47
+ // Everything the owner types for a session is queued here. A picture the message names in a
48
+ // folder no session may read (a screenshot saved under ~/Library) is first copied to where
49
+ // the session can read it, and the message names the copy.
50
+ const enqueue = (root, goal, options) => queueRequest(root, readablePictures(goal).text, options);
51
+
52
+ // All display text is untrusted, including tool output and task titles.
53
+ export function plain(value) { return redact(String(value ?? '')).replace(/[\x00-\x1f\x7f-\x9f]/g, ' '); }
54
+ const clip = (value, width) => { const text = plain(value); return text.length > width ? `${text.slice(0, Math.max(0, width - 3))}...` : text; };
55
+ // Key hints joined by a dot, on as many rows as the width needs. A hint is never split.
56
+ const keyRows = (text, width) => text.split(' · ').reduce((rows, part) => { const last = rows.at(-1); if (last !== undefined && `${last} · ${part}`.length <= width) rows[rows.length - 1] = `${last} · ${part}`; else rows.push(part); return rows; }, []);
57
+ const cost = runs => {
58
+ const known = runs.filter(run => Number.isFinite(run.costUsd));
59
+ if (!known.length) return 'not reported';
60
+ return `${known.length < runs.length ? 'at least ' : ''}$${known.reduce((sum, run) => sum + run.costUsd, 0).toFixed(2)}`;
61
+ };
62
+ export function todayCost(projects, now = new Date()) {
63
+ const day = now.toDateString();
64
+ return cost(projects.flatMap(project => project.tasks.flatMap(task => task.runs.filter(run => new Date(run.at ?? task.createdAt ?? '').toDateString() === day))));
65
+ }
66
+ // The Usage tab. A reset time is the clock when it is today, else the day and the clock.
67
+ const USAGE_NAMES = { claude: 'Claude', codex: 'Codex', agy: 'Antigravity', grok: 'Grok Build' };
68
+ const resetTime = (seconds, now) => { const at = new Date(seconds * 1000); return at.toDateString() === now.toDateString() ? at.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) : at.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).replace(',', ''); };
69
+ const dayText = value => Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString('en-US', { month: 'short', day: 'numeric' }) : 'unknown';
70
+ const pct = value => `${Math.round(value)}%`;
71
+ // Claude's usage is what Claude Code reports during a session, so none shows before one.
72
+ const CLAUDE_UNSEEN = 'Usage shows after your next session on Claude Code.';
73
+ // One window: a six-cell bar, the percent, the reset. A window the vendor did not report says so.
74
+ function usageWindow(name, row, now) {
75
+ if (row?.usedPercent == null) return `${name} [------] — ${row?.resetsAt ? `resets ${resetTime(row.resetsAt, now)}` : 'not reported'}`;
76
+ const percent = Math.round(row.usedPercent), used = Math.round(percent * 6 / 100);
77
+ return `${name} [${'#'.repeat(used)}${'-'.repeat(6 - used)}] ${pct(percent).padStart(4)} ${row.resetsAt ? `resets ${resetTime(row.resetsAt, now)}` : 'reset not reported'}`;
78
+ }
79
+ const otherText = other => [other.account, `last seen ${dayText(other.lastSeen)}`, ...(other.usage?.weekly?.usedPercent != null ? [`wk ${pct(other.usage.weekly.usedPercent)} then`] : other.usage?.fiveHour?.usedPercent != null ? [`5h ${pct(other.usage.fiveHour.usedPercent)} then`] : [])].join(' · ');
80
+ // One block per vendor from usageTab() rows: the account and plan, the two windows, other
81
+ // accounts seen, then how to switch. Each line names its vendor so Enter can open that
82
+ // vendor's details.
83
+ export function usageBlocks(rows, { now = new Date() } = {}) {
84
+ const out = [];
85
+ for (const row of rows) {
86
+ const name = (USAGE_NAMES[row.vendor] ?? row.vendor).padEnd(12), indent = ' '.repeat(12);
87
+ const add = (text, tone = 'text', bold = false) => out.push({ vendor: row.vendor, text, tone, bold });
88
+ if (!row.installed) { add(`${name}not installed`, 'muted'); continue; }
89
+ // Never run yet, so its sign-in was never read: what starts its one-time check, not "not signed in".
90
+ if (row.unchecked) { add(`${name}${FIRST_CHECK}`, 'muted'); continue; }
91
+ if (!row.signedIn) { add(`${name}not signed in. ${row.signInHint}`, 'muted'); continue; }
92
+ add(name + [row.active?.account ?? 'signed in', row.active?.plan, 'active'].filter(Boolean).join(' · '), 'text', true);
93
+ if (row.usage || row.vendor === 'codex') { add(indent + usageWindow('5h', row.usage?.fiveHour, now)); add(indent + usageWindow('wk', row.usage?.weekly, now)); }
94
+ else add(indent + (row.vendor === 'claude' ? CLAUDE_UNSEEN : 'usage not reported'), 'muted');
95
+ for (const other of row.others ?? []) add(indent + otherText(other), 'muted');
96
+ if (row.switchHint) add(indent + row.switchHint, 'muted');
97
+ }
98
+ return out;
99
+ }
100
+ // The details dialog for one vendor: when its usage was reported, both windows with their resets, other accounts.
101
+ export function usageDetailLines(row, now = new Date()) {
102
+ if (!row.installed) return ['Not installed.'];
103
+ if (row.unchecked) return [uncheckedText(row.vendor)];
104
+ if (!row.signedIn) return [`Not signed in. ${row.signInHint}`];
105
+ const lines = [[row.active?.account ?? 'signed in', row.active?.plan, row.active?.tier].filter(Boolean).join(' · ')], usage = row.usage;
106
+ if (!usage) lines.push('', row.vendor === 'claude' ? CLAUDE_UNSEEN : 'Usage not reported.');
107
+ else lines.push(`Reported ${clockTime(usage.observedAt)}`, '', usageWindow('5h', usage.fiveHour, now), usageWindow('wk', usage.weekly, now));
108
+ if (row.others?.length) lines.push('', 'Other accounts', ...row.others.map(other => [other.account, other.plan, `first seen ${dayText(other.firstSeen)}`, otherText(other).split(' · ').slice(1).join(' · ')].filter(Boolean).join(' · ')));
109
+ if (row.switchHint) lines.push('', row.switchHint);
110
+ return lines;
111
+ }
112
+ export const geometry = terminalLayout;
113
+ const PEER_OFF = 'Peer mode is off. Type /peer on to start it.';
114
+ const NO_PROJECT = 'Create a project first: press n in the Projects tab.';
115
+ const PEER_STOPPED = 'The peer daemon is not running. Run elu peer on.';
116
+ const PEER_MISSING = 'Peer mode needs the optional networking packages. Reinstall with optional dependencies.';
117
+ const WEEK = 7 * 24 * 60 * 60 * 1000;
118
+ // The facade never loads the networking packages itself; a missing module only hides peer features.
119
+ async function loadPeer() { try { return await import('./peer/index.mjs'); } catch { return null; } }
120
+ // One chat message as a block: header, then text behind the speaker's bar. Built once per text and width.
121
+ const blocks = new Map();
122
+ function chatMessage({ title, at = null, text, tone, shaded = false, raw = false }, width, colour) {
123
+ const time = at ? messageTime(at) : '', key = [width, tone, shaded, colour, title, time, raw, text].join('\x00');
124
+ let value = blocks.get(key);
125
+ if (value === undefined) {
126
+ // raw: a command and its output keep their asterisks and backticks.
127
+ value = chatBlock({ title, time, text: raw ? blockText(text) : blockText(text).replace(/\*\*([^*\n]+)\*\*/g, '$1').replace(/`([^`\n]+)`/g, '$1'), tone, shaded, width, colour });
128
+ blocks.set(key, value); if (blocks.size > 500) blocks.delete(blocks.keys().next().value);
129
+ }
130
+ return value;
131
+ }
132
+ // trusted: text from a trusted teammate shows as sent; control characters still go.
133
+ function blockText(value, { trusted = false } = {}) {
134
+ const text = String(value ?? '');
135
+ return (trusted ? text.slice(0, 512000) : redact(text, 512000)).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '') + (text.length > 512000 ? '\n[Display truncated; inspect the session worktree for the complete content.]' : '');
136
+ }
137
+ // The pickers: a number, the choice, what it means.
138
+ const PICK = (number, label, text) => `${number} ${label.padEnd(19)}${text}`;
139
+ const MODE_TEXT = { ask: 'Asks before outside edits, escapes, and internet.', auto: 'A reviewer model, not you, decides escapes and internet.', full: 'Any file, any command, the internet.' };
140
+ const PLACE_TEXT = { worktree: 'A copy of this repo on a new branch, feat/task-N.', branch: 'This repo on a branch you name.', here: 'This checkout, as it is. Full access only.' };
141
+ const placeHint = place => place?.kind === 'branch' ? `This repo on branch ${place.branch}.` : place?.kind === 'here' ? 'This checkout, as it is.' : 'A copy of this repo on a new branch.';
142
+ const PLACE_LABEL = { worktree: 'New worktree', branch: 'Branch', here: 'Here' };
143
+ const READ_ONLY_PEERS = 'Teammate questions stay read-only.';
144
+ const SCHEDULE_CAP = 'Schedules run with Approve for me at most.';
145
+ const placeText = place => place?.kind === 'branch' ? `Branch ${place.branch}` : PLACE_LABEL[place?.kind] ?? PLACE_LABEL.worktree;
146
+ const expandHome = value => { const text = String(value ?? '').trim(); return text === '~' ? os.homedir() : text.startsWith('~/') ? path.join(os.homedir(), text.slice(2)) : text; };
147
+ const shortHome = value => { const home = os.homedir(); return value === home ? '~' : value.startsWith(`${home}/`) ? `~${value.slice(home.length)}` : value; };
148
+ // A bot runs under the role bot:name; the owner reads @name.
149
+ const roleLabel = role => botOfKey(role) ? `@${botOfKey(role)}` : role;
150
+ // A bot's scheduled turn runs with Approve for me at most, whatever its session's mode.
151
+ const scheduledTurn = (task, role) => { const bot = botOfKey(role); return Boolean(bot) && (task.messages ?? []).findLast(row => row.to === `@${bot}`)?.scheduled === true; };
152
+ // Whether what this role asks in this session can be given Full access: the owner's own
153
+ // session, for the model or a bot of its chat. Never a teammate's question, a session from
154
+ // before modes, another role, or a bot's scheduled turn.
155
+ const fullAllowed = (task, role) => Boolean(task) && !isPeerRun(task.origin) && MODES.includes(task.permissions) && (!role || role === 'lead' || Boolean(botOfKey(role))) && !scheduledTurn(task, role);
156
+ // What ELU Code never answers for the owner, in any mode: a removal with rm or rmdir, an MCP
157
+ // tool, and an action too long to read whole. A vendor asks about some of these in Full
158
+ // access too, and that question is the owner's.
159
+ const REMOVAL = /(?:^|[\s;&|(`'"/=])(?:rm|rmdir)(?=$|[\s;&|)`'"])/;
160
+ const ownersToAnswer = row => /^mcp__/.test(row.tool ?? '') || (row.summary ?? '').length >= SUMMARY_LIMIT || REMOVAL.test(row.summary ?? '');
161
+ // The mode the turn in progress started with: the session's before the first switch made
162
+ // since the turn began.
163
+ function turnMode(task) {
164
+ const times = [task.activeSince, (task.messages ?? []).findLast(row => !row.from)?.at].map(value => Date.parse(value ?? '')).filter(Number.isFinite);
165
+ const first = times.length ? (task.permissionChanges ?? []).find(row => Date.parse(row.at) > Math.max(...times)) : null;
166
+ return first ? first.from : task.permissions ?? null;
167
+ }
168
+ // A saved request as it was typed: one written to a bot starts with its @name again.
169
+ const typedGoal = row => row.bot ? `@${row.bot} ${row.goal}` : row.goal;
170
+ // What a denied action was, for the Blocked line: "Blocked: Search the web."
171
+ function blockedText(denial) {
172
+ const tool = String(denial?.tool ?? denial?.tool_name ?? ''), input = denial?.input ?? denial?.tool_input ?? {}, field = name => typeof input?.[name] === 'string' ? input[name] : '';
173
+ const text = denial?.summary ? String(denial.summary) : tool === 'Bash' ? `Run ${field('command')}` : ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool) ? `Edit ${shortHome(field('file_path') || field('notebook_path'))}` : tool === 'Read' && field('file_path') ? `Read ${shortHome(field('file_path'))}` : tool === 'WebFetch' ? `Open ${field('url')}` : tool === 'WebSearch' ? 'Search the web' : `Use ${tool || 'a tool'}`;
174
+ return plain(text).replace(/\s+/g, ' ').trim().slice(0, 160);
175
+ }
176
+ // One Blocked line, and whether a wider mode could allow what it names: only then does the
177
+ // /permissions hint belong under it. What no mode allows says so: a never-list command by
178
+ // its name, with the line it was part of; a secret or ELU Code's own file; and in Full
179
+ // access the owner's own answer, or a rule this cannot name. run: the run that was refused;
180
+ // asked: its approvals.
181
+ function blockedLine(denial, run, asked = []) {
182
+ const what = blockedText(denial), why = whyBlocked(denial, { mode: run?.permissions ?? null, vendor: run?.vendor ?? null, asked });
183
+ if (!why) return { text: what, lifts: true };
184
+ if (why.kind === 'never') { const line = plain(why.line).replace(/\s+/g, ' ').trim(); return { text: `${neverSentence(why.command)}${line === why.command ? '' : ` (${why.alone ? '' : 'part of: '}${clip(line, 120)})`}`, lifts: false }; }
185
+ const said = { secret: 'files that may hold secrets stay closed in every mode', own: "ELU Code's own files stay closed in every mode", owner: 'you denied it', timeout: 'you did not answer in time', rule: 'refused by a rule that no mode lifts' }[why.kind];
186
+ return { text: `${what}: ${said}`, lifts: false };
187
+ }
188
+ // Absolute paths in a trusted teammate's message that exist on this machine, as the folders
189
+ // the lead may read: a file offers its folder. Paths that fail the folder
190
+ // check are left out.
191
+ export function foldersNamed(text) {
192
+ const out = [];
193
+ for (const match of String(text ?? '').matchAll(/(?<![\w:/.~-])(?:~|\/[\w.@+-]+)(?:\/[\w.@+-]+)+\/?/g)) {
194
+ const value = expandHome(match[0].replace(/\/$/, ''));
195
+ let dir; try { dir = fs.statSync(value).isDirectory() ? value : path.dirname(value); } catch { continue; }
196
+ try { for (const folder of checkFolders([dir])) if (!out.includes(folder)) out.push(folder); } catch { /* Not a folder the lead may read. */ }
197
+ }
198
+ return out.slice(0, 8);
199
+ }
200
+
201
+ // A list's letter keys also answer with Shift held (Caps Lock, Y for y).
202
+ const withShift = keys => keys.flatMap(key => /^[a-z]$/.test(key) ? [key, `S-${key}`] : [key]);
203
+ function bindInput(field, screen) {
204
+ // blessed hands the terminal to $EDITOR on e (field focused but not reading), Ctrl-E and a
205
+ // right-click. ELU Code never does: those start typing in place instead.
206
+ field.readEditor = function() { return this.readInput(); };
207
+ // A field owns exactly one listener while focused. The library's deferred
208
+ // listener registration can attach stale handlers after rapid view changes.
209
+ field.readInput = function(callback) {
210
+ if (this._reading || this.destroyed) return;
211
+ this._reading = true;
212
+ if (screen.focused !== this) this.focus();
213
+ screen.grabKeys = true; this._updateCursor(); screen.program.showCursor();
214
+ const listener = (ch, key) => this._listener(ch, key);
215
+ const finish = (error, value) => {
216
+ if (!this._reading || this._done !== finish) return;
217
+ this._reading = false; this.removeListener('keypress', listener); this.removeListener('blur', blur);
218
+ delete this.__listener; delete this.__done; delete this._done;
219
+ screen.grabKeys = false; screen.program.hideCursor();
220
+ if (error === 'stop') return;
221
+ if (error) this.emit('error', error);
222
+ else this.emit(value == null ? 'cancel' : 'submit', value);
223
+ this.emit('action', value); callback?.(error, value);
224
+ };
225
+ // A click on the focused field makes blessed focus it again, which blurs it in place; only
226
+ // focus moving elsewhere ends the reading.
227
+ const blur = () => { if (screen.focused !== this) finish(null, null); };
228
+ this._done = finish; this.__listener = listener; this.__done = blur;
229
+ this.on('keypress', listener); this.on('blur', blur);
230
+ };
231
+ }
232
+
233
+ // Six tabs. Sessions lists every session across projects on the left and
234
+ // shows the selected one's chat on the right. + New session shows the same chat empty, with
235
+ // the project, its model and its bots above the box. Projects lists every project. Kanban holds the
236
+ // open project's tasks in four columns. Chats holds channels and direct messages, each with
237
+ // its threads. Bots lists the saved bots. Usage shows the vendor accounts and their plan usage.
238
+ // usage: where the Usage tab reads from ({ env, readFile, vendors }); tests pass a temp home
239
+ // and fixed vendor rows.
240
+ export async function launchTerminal({ repo, startDirectory = process.cwd(), input = process.stdin, output = process.stdout, refreshMs = 350, onRender, startConductor = launchConductor, peer, team: teamStart = false, askProject = false, clipboard = null, loginItem = { install: installScheduler, uninstall: uninstallScheduler }, usage = {} } = {}) {
241
+ if (!input.isTTY || !output.isTTY) throw new Error('Open ELU Code in a terminal, or use --json for a snapshot.');
242
+ const facade = peer === undefined ? await loadPeer() : peer;
243
+ const colour = process.env.NO_COLOR === undefined;
244
+ const shade = name => colour ? PALETTE[name] : 'default';
245
+ const accent = shade('orange'), muted = shade('muted');
246
+ const screen = blessed.screen({ input, output, terminal: process.env.TERM || 'xterm', smartCSR: true, fullUnicode: true, title: 'ELU Code', warnings: false, autoPadding: false, dockBorders: false, ignoreLocked: ['C-c', 'tab', 'S-tab'] });
247
+ blessed.box({ parent: screen, top: 0, left: 0, width: '100%', height: '100%', style: { bg: shade('background') } });
248
+ const box = options => blessed.box({ parent: screen, border: 'line', tags: false, style: { fg: shade('text'), bg: shade('panel'), border: { fg: shade('border') }, label: { fg: muted, bold: true } }, ...options });
249
+ // Lists highlight their selected row in full only while focused; the row keeps its own colours.
250
+ const selection = { fg: shade('text'), bg: shade('highlight'), inverse: !colour, bold: true, underline: false };
251
+ const QUIET = { fg: shade('text'), bg: shade('raised'), inverse: false, bold: false, underline: !colour };
252
+ const pane = (parent, options = {}) => blessed.list({ parent, top: 1, left: 1, width: '100%-2', height: '100%-2', keys: true, mouse: true, vi: false, tags: true, invertSelected: false, style: { fg: shade('text'), bg: shade('panel'), selected: { ...selection } }, scrollbar: { ch: '┃', style: { fg: shade('dim') } }, ...options });
253
+ const button = (top, content) => blessed.button({ parent: screen, top, height: 1, mouse: true, keys: true, tags: false, content, style: { fg: muted, bg: shade('raised') } });
254
+ // Row 0: the brand (the open project's name, else ELU Code), the six tabs, and live status.
255
+ blessed.box({ parent: screen, top: 0, left: 0, width: '100%', height: 1, style: { bg: shade('raised') } });
256
+ const BRAND = ' ◆ ELU Code ';
257
+ const brand = blessed.text({ parent: screen, top: 0, left: 0, width: BRAND.length, height: 1, tags: false, content: BRAND, style: { fg: accent, bg: shade('raised'), bold: true } });
258
+ const pills = blessed.text({ parent: screen, top: 0, height: 1, tags: true, style: { fg: muted, bg: shade('raised') } });
259
+ const tabs = [['Sessions', 'tasks'], ['Projects', 'projects'], ['Kanban', 'kanban'], ['Chats', 'chats'], ['Bots', 'bots'], ['Usage', 'usage']].map(([name, viewName]) => Object.assign(button(0, ` ${name} `), { viewName }));
260
+ // Row 1: what the view offers. Projects: + New project. Bots: + New bot. A session opened from Kanban: the way back.
261
+ const newBot = button(1, ' + New bot ');
262
+ const newProject = button(1, ' + New project ');
263
+ const back = button(1, ' ‹ Kanban ');
264
+ const header = blessed.text({ parent: screen, top: 1, height: 1, tags: true, style: { fg: shade('text'), bg: shade('background') } });
265
+ // Sessions: + New session, then needs you, running, queued and recent, across every project.
266
+ const boardBox = box({});
267
+ const board = pane(boardBox, { keys: false });
268
+ // Chats: the bar of channels and direct messages beside the open one's threads.
269
+ const teamBoxes = [' Team ', ' Threads '].map(label => box({ label, hidden: true }));
270
+ const teamLists = teamBoxes.map(parent => pane(parent));
271
+ // Over a channel's threads: who is in it. It stays in place while the threads scroll.
272
+ const chatMembers = blessed.text({ parent: teamBoxes[1], top: 1, left: 1, width: '100%-3', height: 1, tags: true, hidden: true, style: { fg: muted, bg: shade('panel') } });
273
+ const teamOff = box({ hidden: true, label: ' Chats ', padding: { left: 1 } });
274
+ // Kanban: the open project's tasks in four columns.
275
+ const kanbanBoxes = COLUMNS.map(([, name]) => box({ label: ` ${name} `, hidden: true }));
276
+ const kanbanLists = kanbanBoxes.map(parent => pane(parent));
277
+ const kanbanOff = box({ hidden: true, label: ' Kanban ', padding: { left: 1 } });
278
+ // Teammates' Tasks in this project, under the four columns. Nothing in it can be changed here.
279
+ const kanbanTeam = box({ hidden: true, label: ' Teammates ', padding: { left: 1 } });
280
+ const kanbanTeamList = pane(kanbanTeam, { keys: false });
281
+ let teamTaskRows = [], teamTasks = { at: 0, root: null, people: [], sharing: 'none' };
282
+ const newCard = button(1, ' + New task ');
283
+ // Bots: one list of the saved bots.
284
+ const botsBox = box({ label: ' Bots ', hidden: true });
285
+ const botsList = pane(botsBox);
286
+ // Projects: one list of every project. Usage: one list of the accounts and their plan usage.
287
+ const projectsBox = box({ label: ' Projects ', hidden: true });
288
+ const projectsList = pane(projectsBox);
289
+ const usageBox = box({ label: ' Usage ', hidden: true });
290
+ const usageList = pane(usageBox);
291
+ // A task's chat, or the New task form when no task is open.
292
+ const activity = box({ tags: true, scrollable: true, alwaysScroll: true, mouse: true, keys: true, padding: { left: 2, right: 2 }, scrollbar: { ch: '┃', style: { fg: shade('dim') } } });
293
+ // What the open session changed, in the chat's place. Only the rows in view are drawn, so a
294
+ // long diff costs no more than a short one. A row is never wrapped: it already fits.
295
+ const changesBox = box({ tags: true, wrap: false, hidden: true });
296
+ // The switch between the two, on the top border of whichever shows: Chat Changes 3.
297
+ const sessionTabs = blessed.text({ parent: screen, height: 1, tags: true, hidden: true, autoFocus: false, style: { fg: muted } });
298
+ const compose = box({ label: ' New session ', hidden: true });
299
+ const composeList = pane(compose, { height: '100%-5' });
300
+ const composeHint = blessed.text({ parent: compose, bottom: 1, left: 2, width: '100%-4', height: 2, tags: true, style: { fg: muted, bg: shade('panel') } });
301
+ const status = blessed.text({ parent: screen, height: 1, tags: true, style: { fg: muted, bg: shade('background') } });
302
+ // The message box wraps over four lines. Enter sends; newlines inside a paste are kept.
303
+ const ask = blessed.textarea({ parent: screen, border: 'line', label: ' Message ', inputOnFocus: false, keys: true, mouse: true, tags: false, padding: { left: 1, right: 1 }, style: { fg: shade('text'), bg: shade('raised'), border: { fg: accent }, label: { fg: accent, bold: true } } });
304
+ // Its label names the session's bots, each in its own colour, so the label alone reads tags.
305
+ ask._label.parseTags = true;
306
+ bindInput(ask, screen); editable(ask);
307
+ ask.submit = function() { return this.__listener?.('\r', { name: 'enter' }); };
308
+ const keysBar = blessed.text({ parent: screen, height: 1, tags: true, style: { fg: muted, bg: shade('background') } });
309
+ const palette = blessed.list({ parent: screen, hidden: true, border: 'line', label: ' Commands: arrows / Tab / Enter ', tags: false, keys: false, mouse: true, style: { fg: shade('text'), bg: shade('raised'), border: { fg: accent }, selected: { fg: shade('background'), bg: accent, inverse: !colour, bold: true } }, scrollbar: { ch: '┃' } });
310
+ const sizeNotice = box({ top: 0, left: 0, width: '100%', height: '100%', hidden: true, content: 'ELU Code\nResize to at least 80 columns by 24 rows.\nCtrl-C quits.' });
311
+ let claim = { readOnly: true, release() {} }; let controller; let snapshot; let modal = null; let closing = false; let selectedId = null; let selectedRequest = null; let message = ''; let busy = false; let usageEnabled = false; let kanbanData = COLUMNS.map(([column, name]) => ({ column, name, items: [] })), returnTo = 'tasks'; let paletteDismissed = null; let paletteValues = []; let paletteText = null; let composeWhen = null; let following = null; let drawing = false; let pendingDraw = null;
312
+ let view = teamStart ? 'chats' : 'tasks';
313
+ // Sessions: the row the cursor is on (NEW is + New session), the row the right side was
314
+ // last shown for, and the open session's own row as of the last draw.
315
+ const NEW = 'new'; let barKey = NEW, barAt = 0, openKey = null, ownSeen = null, wanted = null, opening = false, dash = null, sided = null;
316
+ let shellRun = null, shellTyped = false, chatTarget = null, chatOpen = null, chatJump = null, boardLines = [], composeRows = [], teamRows = [[], []], projectRows = [], botRows = [], botJump = null, teamOn = false, peerData = { on: false }, lastPane = { chats: null, kanban: null };
317
+ // The Usage tab: its rows (one vendor each), the data behind them, whether a check is running.
318
+ let usageRows = [], usageData = null, usageKey = null, usageBusy = false; const usageAbort = new AbortController();
319
+ // The new session's permissions: mode, where it works, extra folders. Approvals are the lead's live requests.
320
+ let composeMode = null, composePlace = { kind: 'worktree' }, composeFolders = [], approvals = [];
321
+ // The bots a new session starts with (/bots add in the form), and the saved bots, read at
322
+ // most once a second: the key bar and the @ list ask on every draw.
323
+ let composeBots = [], botCache = { at: 0, rows: [] };
324
+ // Archived sessions are left out of the list until /archived, or a click on the line that
325
+ // counts them, asks for them.
326
+ let showArchived = false;
327
+ const toggleArchived = () => { showArchived = !showArchived; setMessage(showArchived ? 'Archived sessions are listed last. /archived hides them again.' : 'Archived sessions are hidden.'); };
328
+ // The chat follows its newest line while its end is in view. Once the owner scrolls up it
329
+ // stays on the lines he chose, through every redraw and whatever arrives below, until he
330
+ // is back at the end or another chat opens. chatShown names the chat the pane last drew.
331
+ let chatShown = null;
332
+ const scrolledUp = pane => { const at = pane.getScrollPerc(true); return at >= 0 && at < 100; };
333
+ const savedBots = () => { if (Date.now() - botCache.at > 1000) { let rows = []; try { rows = listBots(); } catch { /* An unreadable file offers none. */ } botCache = { at: Date.now(), rows }; } return botCache.rows; };
334
+ // The colour a bot shows in, by its name: one that is no longer saved still has one. You
335
+ // are blue and the session's model is orange; a bot is never plain.
336
+ const toneOfBot = name => botColor(savedBots().find(bot => bot.name === name) ?? { name });
337
+ // "@reviewer @elu +2" with each name in its bot's colour; the other words in rest.
338
+ const paintBots = (text, rest = null) => text.split(' ').map(word => word.startsWith('@') ? ink(word, toneOfBot(word.slice(1)), colour, true) : ink(word, rest, colour, Boolean(rest))).join(' ');
339
+ // The bots a new session starts with, and the ones of a session saved without a list: what
340
+ // sessionBots gives a session with no list. Read at most once a second, like the saved
341
+ // bots, because labels ask on every draw. A session's own list, even an empty one, stands.
342
+ let firstCache = { at: 0, rows: [] };
343
+ const firstBots = () => { if (Date.now() - firstCache.at > 1000) { let rows = []; try { rows = sessionBots(null); } catch { /* An unreadable file gives none. */ } firstCache = { at: Date.now(), rows }; } return firstCache.rows; };
344
+ const botsOf = task => Array.isArray(task?.bots) ? task.bots : firstBots();
345
+ // Who a message with no @name goes to in a session: the bot the owner last wrote to there,
346
+ // while it is still in the session's chat, else null for the session's model. A message of
347
+ // theirs that still waits counts as written. @model writes to the model whoever came last.
348
+ const TO_MODEL = /^@model(?=\s|$)/i;
349
+ function addressedBot(task, rows) {
350
+ if (!task || task.origin) return null;
351
+ const waits = rows.filter(row => row.continuationTaskId === task.id && ['queued', 'starting'].includes(row.state) && !row.scheduled && !row.origin)
352
+ .sort((a, b) => Date.parse(a.queuedAt ?? a.createdAt) - Date.parse(b.queuedAt ?? b.createdAt)).at(-1);
353
+ const last = waits ? waits.bot ?? null : (task.messages ?? []).findLast(row => !row.bot && !row.from && !row.scheduled)?.to?.replace(/^@/, '') ?? null;
354
+ return last && botsOf(task).includes(last) && savedBots().some(bot => bot.name === last) ? last : null;
355
+ }
356
+ composeBots = firstBots();
357
+ // The new session's model, { vendor, model }: null is the project's default. botTyped is
358
+ // the saved bot the message box's text is written to, so the label is drawn again when it changes.
359
+ let composeModel = null, botTyped = null, personTyped = null, modelTyped = false, reported = { at: 0, root: null, rows: [] };
360
+ // The models earlier runs of the open project reported, so a model chosen by a short name
361
+ // is shown by its full one. Read at most every two seconds: labels ask on every draw.
362
+ const knownModels = () => {
363
+ const root = controller?.repo.root ?? null;
364
+ if (root !== reported.root || Date.now() - reported.at > 2000) { let rows = []; try { rows = controller ? reportedModels(controller.snapshot().tasks) : []; } catch { /* Shown by the chosen name. */ } reported = { at: Date.now(), root, rows }; }
365
+ return reported.rows;
366
+ };
367
+ const nameOf = choice => modelLabel(choice, knownModels());
368
+ // A bot's model by name, for its page and its row: the name a session shows that model
369
+ // by. A bot that names no model runs on its vendor's own, so it is the model the bot's
370
+ // latest run in the open project reported, and null before one did.
371
+ let botRan = { at: 0, root: null, rows: new Map() };
372
+ const botModelName = bot => {
373
+ if (bot.model) return nameOf({ vendor: bot.vendor, model: bot.model });
374
+ const root = controller?.repo.root ?? null;
375
+ if (root !== botRan.root || Date.now() - botRan.at > 2000) {
376
+ const rows = new Map();
377
+ try {
378
+ for (const task of controller ? controller.snapshot().tasks : []) for (const run of task.runs ?? []) {
379
+ const key = `${botOfKey(run.role)}\0${run.vendor}`;
380
+ if (botOfKey(run.role) && run.model && !(String(rows.get(key)?.at ?? '') > String(run.at))) rows.set(key, run);
381
+ }
382
+ } catch { /* The row says who picks the model. */ }
383
+ botRan = { at: Date.now(), root, rows };
384
+ }
385
+ return modelName(botRan.rows.get(`${bot.name}\0${bot.vendor}`)?.model);
386
+ };
387
+ // The model a session's next message goes to, named as its chat titles name it.
388
+ const modelOf = task => modelLabel(sessionModel(controller.repo, task) ?? controller.defaultModel(), [...knownModels(), ...reportedModels([task])]);
389
+ const drafts = new Map();
390
+ const draftKey = () => controller ? `${controller.repo.root}\0${selectedId ?? 'new'}` : null;
391
+ function rememberDraft() { if (controller && view === 'chat' && !ask.getValue().trimStart().startsWith('/')) drafts.set(draftKey(), ask.getValue()); }
392
+ function restoreDraft() { if (controller && view === 'chat') ask.setValue(drafts.get(draftKey()) ?? ''); }
393
+ let resolveDone, rejectDone; const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
394
+ const setMessage = value => { message = plain(value); draw(); };
395
+ const chatWidth = () => Math.max(20, (typeof activity.width === 'number' ? activity.width : screen.width) - activity.iwidth - 1);
396
+ // Drag selects and copies on release; a click in the message box moves its cursor.
397
+ // Tests pass their own clipboard; without one a test copies nothing.
398
+ const copyText = clipboard ?? (process.env.NODE_TEST_CONTEXT ? null : systemClipboard({ write: data => screen.program._write(data) }));
399
+ const copied = text => setMessage(`Copied ${text.length.toLocaleString('en-US')} character${text.length === 1 ? '' : 's'}.`);
400
+ // The bar drawn at the left of a message is not part of its text, so a copy leaves it out.
401
+ const mouseSelection = attachSelection(screen, { copy: copyText, onCopy: copied, clean: row => row.replace(/^\s*┃ ?/, '') });
402
+ ask.selection = () => { const range = mouseSelection.range(); return range?.el === ask ? range : null; };
403
+ // A paste goes into the box being typed in, whole; a single-line box takes it on one line.
404
+ attachPaste(screen, text => {
405
+ const field = screen.focused; if (!field?._reading) return;
406
+ const value = pastedText(text);
407
+ if (value) field._listener(field.type === 'textbox' ? value.replace(/\n/g, ' ') : value, { name: 'paste', sequence: value });
408
+ });
409
+ // Last, so it feeds every reader of the input: each mouse report reaches them on its own.
410
+ attachReports(screen);
411
+ const catchAction = fn => Promise.resolve().then(fn).catch(error => setMessage(error.message));
412
+ const busyHere = () => Boolean(controller?.snapshot().tasks.some(task => ['running', 'preparing'].includes(task.state)));
413
+ const conduct = () => { try { startConductor(); } catch (error) { setMessage(`The conductor could not start: ${error.message}`); } };
414
+ // y approves the Task that waits in a session and starts its work; n rejects it (lib/plan.mjs).
415
+ // The work starts as a message of its own, so it waits its turn like any other.
416
+ const taskWaits = row => row?.kind === 'task' && row.task?.plan?.state === 'proposed';
417
+ function decideTask(root, task, yes) {
418
+ if (['running', 'preparing'].includes(task.state)) throw new Error('This session is working. Answer its Task when it stops.');
419
+ const repo = projectFolder(root);
420
+ if (!yes) { rejectTask(repo, task.id); setMessage('Task rejected. Nothing was changed.'); return; }
421
+ const plan = approveTask(repo, task.id);
422
+ enqueue(root, `Approved the Task: ${plan.title}`, { continuationTaskId: task.id, priority: 0, approved: true }); conduct();
423
+ // Said so that it stays true once the turn has ended: the line stays up until a key clears it.
424
+ setMessage('Task approved.');
425
+ }
426
+ // Bots that have a schedule run by themselves in the sessions they are in. The login item
427
+ // is asked for when one is first on, so they also run while ELU Code is closed.
428
+ const tickBots = () => runBotSchedules(new Date(), { start: conduct, install: loginItem.install });
429
+ const composing = () => view === 'chat' && !selectedId && !selectedRequest && !following;
430
+ // The Sessions tab: the list on the left; a session, the new session form or a row's text
431
+ // on the right. A session opened from Kanban fills the screen instead.
432
+ const listed = () => view === 'tasks' || view === 'chat' && returnTo === 'tasks';
433
+ // The conductor may give a folder its first Git history; this view then follows it.
434
+ function adoptHistory() {
435
+ if (!controller || controller.repo.common || !fs.existsSync(path.join(controller.repo.root, '.git'))) return;
436
+ const next = projectFolder(controller.repo.root); if (!next.common) return;
437
+ controller.repo = next; repo = next; claim.attach?.(next); if (view === 'chat') snapshot = controller.snapshot();
438
+ }
439
+ // A request the conductor has started is its session from then on. The screen learns that
440
+ // at its next draw, so whatever is sent before that draw asks here first: a message saved
441
+ // over the request would start a second session, and /stop would cancel the request of a
442
+ // session that is working.
443
+ function openStarted() {
444
+ if (!selectedRequest || !controller) return null;
445
+ let row; try { row = pendingRequests(controller.repo.root).find(value => value.id === selectedRequest.id) ?? null; } catch { return null; }
446
+ if (row?.taskId) { selectedId = row.taskId; selectedRequest = null; }
447
+ return row;
448
+ }
449
+ // Follows a sent message until the conductor starts it, then shows its chat.
450
+ function follow() {
451
+ if (!following || !controller || controller.repo.root !== following.root) { following = null; return; }
452
+ let row; try { row = pendingRequests(following.root).find(value => value.id === following.id); } catch { return; }
453
+ if (!row) { following = null; return; }
454
+ if (row.taskId && selectedRequest?.id === row.id) { selectedId = row.taskId; selectedRequest = null; }
455
+ if (row.state === 'done') {
456
+ const asked = following.bot; following = null;
457
+ const task = controller.snapshot().tasks.find(value => value.id === row.taskId), reply = asked ? (task?.messages ?? []).findLast(value => value.bot === asked) : null;
458
+ if (task && view === 'chat') message = plain(reply ? reply.failed ? `@${asked}: ${reply.text}` : `@${asked} answered` : task.state === 'answered' ? `${modelOf(task)} answered` : task.state === 'ready' ? 'ready | /publish to review push' : task.state === 'needs_input' ? 'Waiting for your answer' : task.error?.message ?? task.state);
459
+ } else if (row.state === 'blocked') {
460
+ following = null;
461
+ if (!selectedId || selectedId === row.continuationTaskId || selectedRequest?.id === row.id) { selectedRequest = row; if (!ask.getValue()) ask.setValue(typedGoal(row)); }
462
+ message = plain(`Request blocked: ${row.reason} Text saved; Enter restores it.`);
463
+ }
464
+ }
465
+ // Sessions. A line of the list is + New session, a heading, or a row; only the first and the last take the cursor.
466
+ const lineKey = line => line?.add ? NEW : line?.row?.key ?? null;
467
+ // The session a row shows beside the list: a session's chat, a request of yours with the
468
+ // chat it continues, or the session an approval waits in. Any other row shows its own text.
469
+ function sessionOf(row) {
470
+ if (row.kind === 'task') return { root: row.root, id: row.id, request: null };
471
+ if (row.kind === 'request' && !peerIncoming(row)) return { root: row.root, id: row.request.continuationTaskId ?? null, request: row.request };
472
+ if (row.kind === 'approval' && dash?.projects.some(project => project.root === row.root && project.tasks?.some(task => task.id === String(row.id)))) return { root: row.root, id: String(row.id), request: null };
473
+ return null;
474
+ }
475
+ // The row that stands for what is open on the right, or null when a row's own text shows.
476
+ function ownKey() {
477
+ if (view !== 'chat' || returnTo !== 'tasks' || !controller) return null;
478
+ const root = controller.repo.root;
479
+ return selectedId ? `${root}\0task\0${selectedId}` : selectedRequest ? `${root}\0request\0${selectedRequest.id}` : following ? `${root}\0request\0${following.id}` : NEW;
480
+ }
481
+ // The cursor moved to another row by a key or the mouse.
482
+ function pointBar(key) { if (key && key !== barKey) { barKey = key; wanted = null; message = ''; } }
483
+ // A new session starts with the default mode, a new worktree, the project's model, its first bots, and no schedule or extra folders.
484
+ function resetForm() { composeWhen = null; composeMode = defaultMode(); composePlace = { kind: 'worktree' }; composeFolders = []; composeBots = firstBots(); composeModel = null; }
485
+ // The new session form. fresh: n, + or /new asked for it, so it starts over. Moving the
486
+ // cursor back to + New session keeps what the form was set to.
487
+ function showForm(fresh = true) {
488
+ rememberDraft(); view = 'chat'; selectedId = null; selectedRequest = null; following = null;
489
+ if (fresh) resetForm();
490
+ restoreDraft();
491
+ }
492
+ // A session's chat. A request still in the queue is followed into its session when the
493
+ // conductor starts it. opened: Enter was pressed, so a blocked or saved request of yours
494
+ // comes back into the message box.
495
+ function showSession({ id = null, request = null }, opened = false) {
496
+ view = 'chat'; selectedId = id; selectedRequest = request; restoreDraft();
497
+ following = request && ['queued', 'starting'].includes(request.state) ? { root: controller.repo.root, id: request.id, bot: request.bot ?? null } : null;
498
+ if (opened && request && ['blocked', 'waiting'].includes(request.state) && !request.origin && !ask.getValue()) { ask.setValue(typedGoal(request)); message = 'Saved request restored. Send to retry.'; }
499
+ }
500
+ // What the cursor's row shows on the right, without moving focus: the new session form, a
501
+ // session, or the row's own text. The box under a row that is not a session starts a new
502
+ // one, so it keeps the new session's draft.
503
+ function showLine(line, session) {
504
+ const typed = ask.getValue(), fresh = controller ? `${controller.repo.root}\0new` : null;
505
+ if (view === 'tasks' && fresh && !typed.trimStart().startsWith('/')) drafts.set(fresh, typed);
506
+ returnTo = 'tasks';
507
+ if (line.add && controller) showForm(false);
508
+ else if (session && controller) { rememberDraft(); showSession(session); }
509
+ else { rememberDraft(); if (view === 'chat') ask.setValue(drafts.get(fresh) ?? ''); view = 'tasks'; selectedRequest = null; following = null; }
510
+ }
511
+ // The bots on a schedule in each listed session, by project root then session, read again
512
+ // every two seconds: the list and the open session's title line show them. A session
513
+ // opened from the Kanban tab has no list behind it: its own project is read then.
514
+ let timedAt = 0, timedShown = {};
515
+ function timedSessions(now = Date.now()) {
516
+ if (now - timedAt < 2000) return timedShown;
517
+ timedAt = now; timedShown = {};
518
+ for (const project of dash?.projects ?? (snapshot?.tasks ? [snapshot] : [])) {
519
+ if (project.unavailable || !project.tasks?.length) continue;
520
+ try { const shown = botSchedulesShown(projectFolder(project.root), project.tasks, new Date(now)); if (Object.keys(shown).length) timedShown[project.root] = shown; } catch { /* A project that cannot be read shows no schedule. */ }
521
+ }
522
+ return timedShown;
523
+ }
524
+ // Builds the list, keeps the cursor on its row while rows move between groups, and shows
525
+ // that row on the right. A new message elsewhere never moves either of them.
526
+ function syncSessions(now) {
527
+ // Peer threads that wait for nothing live in Chats; asks, replies and drafts for you stay here.
528
+ const rows = boardRows(dash.projects, now, { items: peerData.items, online: onlineKeys(peerData.status), recent: WEEK, schedules: listSchedules(), approvals, archived: showArchived, timed: timedSessions(now) }).filter(row => row.kind !== 'peer' || peerDecision(row));
529
+ const before = boardLines.findIndex(line => lineKey(line) === barKey), at = before < 0 ? barAt : before;
530
+ boardLines = [{ add: true }, { spacer: true }, ...(rows.length ? boardSections(rows) : [{ note: dash.projects.length ? 'No sessions yet.' : 'No project yet.' }])];
531
+ // The archive is out of the way, and one line says it is there.
532
+ const stored = dash.projects.reduce((count, project) => count + (project.closed?.length ?? 0), 0);
533
+ // It sits on the last row of the pane; in a list longer than the pane it is the last line.
534
+ if (stored) boardLines.push(...Array.from({ length: Math.max(1, (typeof board.height === 'number' ? board.height : 0) - boardLines.length - 1) }, () => ({ spacer: true })), { note: showArchived ? 'Hide archived · /archived' : `${stored} archived · /archived`, archive: true });
535
+ const find = key => boardLines.findIndex(line => lineKey(line) === key), has = key => find(key) >= 0;
536
+ const after = boardLines.findIndex((line, place) => place >= at && lineKey(line)), near = after >= 0 ? after : boardLines.findLastIndex(lineKey);
537
+ // The right side changed by itself (a sent message became a session, /new opened the form): the cursor follows it.
538
+ // A message sent from the form used its settings up, and so did leaving the tab.
539
+ const own = ownKey();
540
+ if (own !== ownSeen) { if (ownSeen === NEW) resetForm(); ownSeen = own; wanted = own; }
541
+ if (wanted && has(wanted)) { barKey = openKey = wanted; wanted = null; }
542
+ // The cursor's row left the list: the open session's own row takes it, else the nearest row.
543
+ if (!has(barKey)) {
544
+ if (own && has(own)) barKey = openKey = own;
545
+ else if (!wanted) barKey = lineKey(boardLines[near]);
546
+ }
547
+ if (barKey !== openKey && !opening && has(barKey)) {
548
+ const key = barKey, line = boardLines[find(key)], session = line.row ? sessionOf(line.row) : null;
549
+ wanted = null;
550
+ if (session && session.root !== controller?.repo.root) {
551
+ // A session of another project: that project becomes the active one first.
552
+ opening = true;
553
+ catchAction(async () => {
554
+ try { await switchProject(projectFolder(session.root)); if (barKey === key && listed()) showLine(line, session); }
555
+ finally { opening = false; openKey = key; ownSeen = ownKey(); resize(); }
556
+ });
557
+ } else { try { showLine(line, session); } finally { openKey = key; ownSeen = ownKey(); } }
558
+ }
559
+ // While a sent message waits for its session's row, the cursor rests on the nearest row.
560
+ barAt = has(barKey) ? find(barKey) : near;
561
+ }
562
+ function activateProject(location) {
563
+ const nextClaim = claimView(location); let nextController, nextSnapshot;
564
+ try { nextController = new ProjectController(location, { readOnly: nextClaim.readOnly, onRepository: value => { repo = value; nextClaim.attach(value); } }); nextSnapshot = nextController.snapshot(); }
565
+ catch (error) { nextClaim.release(); throw error; }
566
+ repo = location; claim = nextClaim; controller = nextController; snapshot = nextSnapshot; vendorList.forget();
567
+ if (!claim.readOnly) registerProject(repo);
568
+ selectedId = null; selectedRequest = null; message = '';
569
+ usageEnabled = snapshot.initialized && !claim.readOnly;
570
+ if (usageEnabled) controller.refreshUsage().then(draw);
571
+ if (!claim.readOnly) setImmediate(() => catchAction(() => reviewProjectFiles(location.root)));
572
+ }
573
+ function minimumSize() {
574
+ const small = screen.width < 80 || screen.height < 24;
575
+ sizeNotice.hidden = !small;
576
+ if (small) sizeNotice.setFront();
577
+ return small;
578
+ }
579
+ // Every project with a record, and the open one even when another view owns it.
580
+ function allProjects(value = dashboardSnapshot()) {
581
+ if (controller && !value.projects.some(project => project.root === controller.repo.root)) value.projects.unshift(controller.snapshot());
582
+ return value;
583
+ }
584
+ function resize() {
585
+ const g = geometry(screen.width, screen.height), W = g.width, top = g.top, bottom = g.statusTop, height = bottom - top;
586
+ layoutSession(g);
587
+ layoutChats(g);
588
+ const third = Math.floor(W / 4);
589
+ kanbanBoxes.forEach((frame, index) => Object.assign(frame, { top, left: index * third, width: index === 3 ? W - 3 * third : third, height }));
590
+ Object.assign(kanbanOff, { top, left: 0, width: W, height: 3 });
591
+ Object.assign(botsBox, { top, left: 0, width: W, height });
592
+ Object.assign(projectsBox, { top, left: 0, width: W, height });
593
+ Object.assign(usageBox, { top, left: 0, width: W, height });
594
+ Object.assign(keysBar, { top: g.keysTop, left: 1, width: W - 2 });
595
+ Object.assign(header, { width: Math.max(1, W - (header.left ?? 0) - 1) });
596
+ if (modal) positionModal();
597
+ draw();
598
+ }
599
+ // The bar of Sessions and of Chats: three tenths of the screen, 30 to 44 columns.
600
+ const barWidth = width => Math.min(44, Math.max(30, Math.floor(width * 0.3)));
601
+ // Sessions puts its list down the left side, with the session, the status row and the
602
+ // message box in the rest. Every other view has them across the screen.
603
+ function layoutSession(g) {
604
+ const W = g.width, top = g.top, height = g.statusTop - top, bar = listed() ? barWidth(W) : 0; sided = bar > 0;
605
+ Object.assign(boardBox, { top, left: 0, width: bar || W, height: g.keysTop - top });
606
+ Object.assign(activity, { top, left: bar, width: W - bar, height });
607
+ Object.assign(compose, { top, left: bar, width: W - bar, height });
608
+ Object.assign(status, { top: g.statusTop, left: bar + 1, width: W - bar - 2, height: 1 });
609
+ Object.assign(ask, { top: g.askTop, left: bar, width: W - bar, height: g.askHeight });
610
+ }
611
+ // Teammates beside threads from 100 columns, else above them.
612
+ function layoutChats(g) {
613
+ const W = g.width, top = g.top, height = g.statusTop - top, side = W >= 100, mates = side ? barWidth(W) : 0;
614
+ const stacked = Math.max(4, Math.min(Math.floor(height / 2), teamRows[0].length + 2));
615
+ Object.assign(teamBoxes[0], side ? { top, left: 0, width: mates, height } : { top, left: 0, width: W, height: stacked });
616
+ Object.assign(teamBoxes[1], side ? { top, left: mates, width: W - mates, height } : { top: top + stacked, left: 0, width: W, height: height - stacked });
617
+ Object.assign(teamOff, { top, left: 0, width: W, height: 3 });
618
+ }
619
+ // Everything the timer can change on screen. Other changes render through their own draw.
620
+ let lastFrame = null;
621
+ const watched = () => [header, brand, pills, status, keysBar, ...tabs, newCard, newBot, newProject, back, activity, changesBox, sessionTabs, boardBox, compose, composeHint, ask, teamOff, ...teamBoxes, chatMembers, kanbanOff, ...kanbanBoxes, kanbanTeam, botsBox, projectsBox, usageBox];
622
+ const frame = () => [screen.width, screen.height, screen.focused?.uid, ...watched().map(panel => `${panel.content}\x01${panel._label?.content ?? ''}\x01${panel.style.fg}\x01${panel.style.bg}\x01${panel.style.bold}\x01${panel.style.underline}\x01${panel.style.inverse}\x01${panel.style.border?.fg}\x01${panel.hidden}\x01${panel.position.left}\x01${panel.position.top}\x01${panel.position.width}\x01${panel.position.height}`), ...[board, composeList, ...teamLists, ...kanbanLists, kanbanTeamList, botsList, projectsList, usageList].map(list => `${list.ritems?.join('\n')}\x01${list.selected}\x01${list.style.selected.bg}\x01${list.style.selected.inverse}`), palette.hidden, palette.ritems?.join('\n'), palette.selected, activity.childBase, modal?.uid].join('\x00');
623
+ // Peer files are read at most once per draw, and only by the views that show them.
624
+ const peerOn = () => { try { return Boolean(facade) && (facade.peerSettings ?? savedPeerSettings)().enabled === true; } catch { return false; } };
625
+ const peerRead = (name, ...args) => { try { const value = facade?.[name]?.(...args); return value && typeof value === 'object' ? value : null; } catch { return null; } };
626
+ const peerList = (name, ...args) => { const value = peerRead(name, ...args); return Array.isArray(value) ? value : []; };
627
+ // Trust is my setting about a teammate, read from the peer policy file.
628
+ const peerTrusted = key => { try { return typeof key === 'string' && (facade?.peerPolicy ?? savedPeerPolicy)(key)?.trusted === true; } catch { return false; } };
629
+ const trustedKeys = status => [...new Set((status?.teams ?? []).flatMap(row => row?.members ?? []).map(member => member?.key).filter(key => typeof key === 'string' && key !== status?.key && peerTrusted(key)))];
630
+ // The lead's live approvals, read once per draw; an unreadable folder shows none.
631
+ const pendingNow = () => { try { return pendingApprovals(); } catch { return []; } };
632
+ const approvalsFor = task => approvals.filter(row => String(row.taskId) === String(task.id) && (!row.root || row.root === controller?.repo.root));
633
+ // The first approval a session's own row in the list waits on.
634
+ const rowApproval = row => row?.kind === 'task' ? approvals.find(item => String(item.taskId) === String(row.id) && (!item.root || item.root === row.root)) ?? null : null;
635
+ // Full access set while a turn asks reaches that turn through its approvals: the app
636
+ // answers them for the owner, what waits now and what the turn asks next. Returns how
637
+ // many it allowed. started: the mode the turn began with. Only a turn that began in Ask
638
+ // for approval or Approve for me is answered: one that began in Full access asks only
639
+ // what its vendor asks in every mode, and that stays the owner's, as does a removal, an
640
+ // MCP tool and an action too long to read. The never list is not asked, so it is not
641
+ // allowed here either.
642
+ const allowForFull = (task, rows, started) => {
643
+ let count = 0;
644
+ if (task?.permissions === 'full' && task.state === 'running' && ['ask', 'auto'].includes(started)) for (const row of rows) { if (!fullAllowed(task, row.role) || ownersToAnswer(row)) continue; try { if (answerApproval(row.id, 'allow')) count++; } catch { /* It stays for the owner to answer. */ } }
645
+ return count;
646
+ };
647
+ // Sets a session's mode and returns what the status line says. With no turn in progress
648
+ // the mode holds from the next message. A turn in progress keeps the mode its vendor
649
+ // started with; Full access alone reaches it, through its approvals.
650
+ // location and root: the project of a session the list shows from another project.
651
+ function setMode(given, mode, { location = null, root = controller.repo.root } = {}) {
652
+ const task = location ? given : controller.snapshot().tasks.find(row => row.id === given.id) ?? given, running = task.state === 'running';
653
+ const capped = running && scheduledTurn(task, task.activeRole), was = running ? turnMode(task) : null, now = capped && MODES.includes(was) ? capMode(was, 'auto') : was;
654
+ if (location) saveMode(location, String(task.id), mode); else controller.setPermissions(task.id, mode);
655
+ if (!running || !MODES.includes(now)) return `${LABELS[mode]} from your next message.`;
656
+ if (mode === now) return `${LABELS[mode]}.`;
657
+ if (mode === 'full' && !capped) {
658
+ const waiting = pendingNow().filter(row => String(row.taskId) === String(task.id) && (!row.root || row.root === root) && fullAllowed(task, row.role)), allowed = allowForFull({ ...task, permissions: mode }, waiting, was);
659
+ return `${allowed ? 'Allowed. ' : ''}${waiting.some(ownersToAnswer) ? 'Full access. This turn still asks before rm, rmdir and MCP tools.' : 'Full access: this session no longer asks you.'}`;
660
+ }
661
+ return `${LABELS[mode]} from the next message; this turn ${now === 'ask' ? 'still asks' : `keeps ${LABELS[now]}`}.`;
662
+ }
663
+ // f beside y and n: allow what waits and give its session Full access, as /permissions
664
+ // full does and after the same question. Offered only where Full access can be given and
665
+ // is not the session's mode already; elsewhere the keys stay y and n.
666
+ const offersFull = (task, approval) => Boolean(approval) && task?.permissions !== 'full' && fullAllowed(task, approval.role);
667
+ const approvalKeys = (task, approval) => `y allow · n deny${offersFull(task, approval) ? ' · f full access' : ''}`;
668
+ // The session an approval waits in, in any project the list shows.
669
+ const approvalSession = approval => (dash?.projects ?? allProjects().projects).find(project => project.root === approval.root)?.tasks?.find(task => task.id === String(approval.taskId)) ?? null;
670
+ const rowProject = row => row.root === controller?.repo.root ? {} : { location: projectFolder(row.root), root: row.root };
671
+ async function allowFull(task, approval, where = {}) {
672
+ if (!offersFull(task, approval)) throw new Error('Full access is not offered for this one. y allows it, n denies it.');
673
+ if (!await confirmFull()) return;
674
+ // The one f was pressed on is the owner's own answer, whatever it asks.
675
+ const answered = answerApproval(approval.id, 'allow'), said = setMode(task, 'full', where);
676
+ setMessage(answered && !said.startsWith('Allowed. ') ? `Allowed. ${said}` : said);
677
+ }
678
+ // A task's chat glances at peer status for the top bar at most every 5 s.
679
+ let peerGlance = { at: 0, value: { on: false } };
680
+ // A daemon that died while peer mode is on starts again, at most once a minute.
681
+ let peerRevivedAt = 0;
682
+ const peerFrame = () => revivePeer(peerFrameNow());
683
+ function revivePeer(frame) {
684
+ if (!frame.on || frame.status?.running !== false || typeof facade?.startPeer !== 'function' || Date.now() - peerRevivedAt < 60000) return frame;
685
+ peerRevivedAt = Date.now();
686
+ Promise.resolve().then(() => facade.startPeer()).then(started => { if (started) setMessage('Peer mode had stopped. Starting it again.'); }, error => setMessage(`Peer mode stopped and could not start again: ${error.message}`));
687
+ return frame;
688
+ }
689
+ function peerFrameNow() {
690
+ if (!facade) return { on: false };
691
+ if (!listed() && ['chat', 'kanban', 'bots', 'projects', 'usage'].includes(view)) {
692
+ if (Date.now() - peerGlance.at > 5000) { const on = peerOn(); peerGlance = { at: Date.now(), value: { on, status: on ? peerRead('peerStatus') : null } }; }
693
+ return peerGlance.value;
694
+ }
695
+ const on = peerOn(), status = on ? peerRead('peerStatus') : null;
696
+ // An item says whether its teammate is trusted; older daemons leave it to the policy.
697
+ const items = () => peerList('peerItems').map(item => item && typeof item === 'object' && item.trusted === undefined && typeof item.peer === 'string' ? { ...item, trusted: peerTrusted(item.peer) } : item);
698
+ if (listed()) return { on, status, items: on ? items() : [] };
699
+ if (!on) return { on, status };
700
+ return { on, status, items: items(), agents: peerList('teamAgents'), trusted: trustedKeys(status), bots: myBots().map(bot => ({ ...bot, tone: botColor(bot) })) };
701
+ }
702
+ const onlineKeys = status => new Set((status?.teams ?? []).flatMap(row => row?.members ?? []).filter(member => member?.online).map(member => member.key));
703
+ const scheduleDraw = () => { if (!pendingDraw && !closing) pendingDraw = setImmediate(() => { pendingDraw = null; draw(); }); };
704
+ const selectedBoardRow = () => boardLines[board.selected ?? 0]?.row ?? null;
705
+ // Where a new task goes from the Tasks input: the open project, the only one, or the selected row's.
706
+ function goalTarget() {
707
+ const projects = (dash ?? snapshot)?.projects ?? [];
708
+ return controller?.repo.root ?? (projects.length === 1 ? projects[0].root : null) ?? ((view === 'projects' ? projectRows[projectsList.selected ?? 0]?.root : selectedBoardRow()?.root) || null);
709
+ }
710
+ const projectName = root => snapshot?.projects?.find(project => project.root === root)?.name ?? path.basename(root);
711
+ // The brand and the terminal's title name the open project, else ELU Code.
712
+ const brandName = () => controller ? clip(snapshot?.name ?? projectName(controller.repo.root), 20) : 'ELU Code';
713
+ // Sessions: the list, then the chat or the form beside it. A row's own text takes no focus.
714
+ // A session's pane is its chat, or its changes while they show.
715
+ const sessionPane = () => changesBox.hidden ? activity : changesBox;
716
+ const viewPanes = () => listed() ? view === 'chat' ? [board, composing() ? composeList : sessionPane()] : [board]
717
+ : view === 'kanban' ? controller ? [...kanbanLists, ...(teamTaskRows.some(row => row.task) ? [kanbanTeamList] : [])] : []
718
+ : view === 'chats' ? teamOn ? teamLists : []
719
+ : view === 'bots' ? [botsList]
720
+ : view === 'projects' ? [projectsList]
721
+ : view === 'usage' ? [usageList]
722
+ : composing() ? [composeList] : [sessionPane()];
723
+ const subButtons = () => view === 'kanban' ? [newCard] : view === 'bots' ? [newBot] : view === 'projects' ? [newProject] : listed() || ['chats', 'usage'].includes(view) ? [] : [back];
724
+ const activeTab = () => tabs.find(tab => tab.viewName === (view === 'chat' ? returnTo : view));
725
+ // In these views the message box starts a new session, as in Sessions.
726
+ const sessionBox = () => ['tasks', 'bots', 'projects', 'usage'].includes(view);
727
+ // The teams a project was joined through (a teammate's team that has it as its project
728
+ // here); none for a project of the owner's own. x leaves the first kind and archives the
729
+ // second. The key bar asks on every draw, so its answer is read at most every two seconds.
730
+ const joinedTeamsOf = root => { const found = root ? peerRead('joinedTeams', root) : null; return Array.isArray(found) ? found : []; };
731
+ let joinedAt = 0, joinedRoot = null, joinedShown = [];
732
+ function joinedInBar(root) {
733
+ const now = Date.now();
734
+ if (root !== joinedRoot || now - joinedAt >= 2000) { joinedRoot = root; joinedAt = now; joinedShown = joinedTeamsOf(root); }
735
+ return joinedShown;
736
+ }
737
+ // The key bar names what each key does for whatever has focus right now.
738
+ function keyPairs() {
739
+ const focused = screen.focused;
740
+ if (tabs.includes(focused)) return [['←→', 'switch'], ['enter', 'open'], ['↓', listed() ? 'sessions' : view === 'kanban' ? 'new task' : view === 'chats' ? 'chats' : view === 'bots' ? 'new bot' : view === 'projects' ? 'new project' : view === 'usage' ? 'usage' : 'back']];
741
+ if (focused === newCard) return [['enter', 'new task'], ['↓', 'board'], ['↑', 'tabs']];
742
+ if (focused === newBot) return [['enter', 'new bot'], ['↓', 'bots'], ['↑', 'tabs']];
743
+ if (focused === botsList) return [['enter', 'open'], ['n', 'new bot'], ['x', 'remove'], ['tab', 'next']];
744
+ if (focused === newProject) return [['enter', 'new project'], ['↓', 'projects'], ['↑', 'tabs']];
745
+ if (focused === projectsList) return [['enter', 'settings'], ['o', 'make active'], ['n', 'new project'], ['x', joinedInBar(projectRows[projectsList.selected ?? 0]?.root).length ? 'leave' : 'archive'], ['tab', 'next']];
746
+ if (focused === usageList) return [['enter', 'details'], ['r', 'refresh'], ['↑↓', 'scroll'], ['tab', 'next']];
747
+ if (focused === kanbanTeamList) return [['enter', 'view'], ['↑↓', 'tasks'], ['tab', 'next']];
748
+ if (kanbanLists.includes(focused)) {
749
+ const index = kanbanLists.indexOf(focused), item = kanbanData[index]?.items[focused.selected ?? 0], column = kanbanData[index]?.column;
750
+ const first = !item ? [] : column === 'todo' ? [['⇧→', 'start'], ['enter', 'edit'], ['x', 'delete']] : column === 'needs' ? [['enter', 'open'], ['⇧→', 'done']] : column === 'done' ? [['enter', 'open'], ['⇧←', 'reopen']] : [['enter', 'open']];
751
+ return [...first, ['←→', 'columns'], ['n', 'new task']];
752
+ }
753
+ if (focused === back) return [['enter', 'back to Kanban'], ['↓', 'chat']];
754
+ if (focused === board && rowApproval(selectedBoardRow())) return [['y', 'allow'], ['n', 'deny'], ...(offersFull(selectedBoardRow().task, rowApproval(selectedBoardRow())) ? [['f', 'full access']] : []), ['enter', 'open'], ['tab', 'next']];
755
+ if (focused === board && selectedBoardRow()?.kind === 'approval' && offersFull(approvalSession(selectedBoardRow().approval), selectedBoardRow().approval)) return [['y', 'allow'], ['n', 'deny'], ['f', 'full access'], ['enter', 'open'], ['n', 'new session'], ['tab', 'next']];
756
+ if (focused === board && taskWaits(selectedBoardRow())) return [['y', 'approve'], ['n', 'reject'], ['enter', 'open'], ['tab', 'next']];
757
+ if (focused === board) return selectedBoardRow() ? [...rowActions(selectedBoardRow()).slice(0, 5), ['n', 'new session'], ['tab', 'next']] : [['enter', 'new session'], ['↑↓', 'choose'], ['tab', 'next']];
758
+ if (focused === composeList) {
759
+ const row = composeRows[composeList.selected ?? 0];
760
+ return [...row?.type === 'project' ? [['enter', 'change project']] : row?.type === 'when' ? [['enter', 'schedule'], ...(composeWhen ? [['x', 'send now instead']] : [])] : ['permissions', 'place', 'model'].includes(row?.type) ? [['enter', 'change']] : row?.type === 'folders' ? [['enter', 'add'], ...(composeFolders.length ? [['x', 'clear']] : [])] : [['enter', 'add or remove'], ...(composeBots.length ? [['x', 'clear']] : [])], ['↓', 'write the message'], ['esc', 'sessions']];
761
+ }
762
+ if (focused === teamLists[0]) {
763
+ const row = teamRows[0][focused.selected ?? 0]?.value;
764
+ if (row?.type === 'add') return [['enter', row.what === 'channel' ? 'new channel' : 'invite a teammate'], ['↑↓', 'choose']];
765
+ if (row?.type === 'room') return [['enter', 'write'], ['n', 'new channel'], ...(row.channel ? [['m', 'members'], ['x', 'leave']] : []), ['tab', 'threads']];
766
+ if (row?.type === 'bot') return [['enter', 'write'], ['n', 'new channel'], ['tab', 'threads']];
767
+ return [['enter', 'write'], ['t', 'trust'], ['n', 'new channel'], ['tab', 'threads']];
768
+ }
769
+ if (focused === teamLists[1]) return [['y', 'accept/send'], ['n', 'decline'], ['c', 'continue'], ['enter', 'read'], ...(chatOpen?.type === 'room' && chatOpen.channel ? [['m', 'members'], ['x', 'leave']] : [])];
770
+ if (focused === ask && !palette.hidden) return [['↑↓', 'choose'], ['tab', 'complete'], ['enter', 'run'], ['esc', 'close']];
771
+ if (focused === ask) {
772
+ if (view === 'chats') return [['enter', 'send'], ['@name', 'one teammate'], ['/', 'commands'], ['!', 'run a shell command']];
773
+ // Only what is not obvious: what Enter does, the command list, choosing the model, and running a shell command.
774
+ const more = [['/', 'commands'], ['/model', 'choose the model'], ['!', 'run a shell command']];
775
+ // With a saved bot, @name is worth knowing too.
776
+ if (savedBots().length) more.unshift(['@name', 'ask a bot']);
777
+ // Beside the list, Esc is the way back to it. It comes last, so it shows where there is room.
778
+ if (listed()) more.push(['esc', 'sessions']);
779
+ if (sessionBox()) return [['enter', 'new session'], ...more];
780
+ if (view === 'kanban') return [['enter', 'add to To do'], ...more.filter(([key]) => key !== '@name')];
781
+ return [['enter', composing() && composeWhen ? 'save the schedule' : 'send'], ...more];
782
+ }
783
+ if (focused === activity) return [['↑↓', 'scroll'], ['esc', 'sessions'], ['i', 'type']];
784
+ if (focused === changesBox) {
785
+ const row = changesShown?.rows[changesShown.cursor], file = row?.kind === 'file' ? changesShown.entry?.changes.files[row.file] : null;
786
+ return [['↑↓', 'move'], ...(file ? [['enter', unfolded(changesShown.entry, file) ? 'fold' : 'unfold']] : []), ['pgup pgdn', 'page'], ['esc', 'chat'], ['tab', 'next']];
787
+ }
788
+ return [['tab', 'next'], ['?', 'help'], ['q', 'quit']];
789
+ }
790
+ const firstWords = (value, width) => fitWords(plain(String(value ?? '').split('\n').find(line => line.trim()) ?? ''), width);
791
+ // How a message names a session: its title in quotes, cut short. No session shows a number.
792
+ const sessionName = task => `"${firstWords(taskTitle(task), 40)}"`;
793
+ const state = task => task.state === 'needs_input' ? 'waiting for you' : approvalsFor(task).length ? 'needs approval' : task.state === 'running' ? task.activeRole && task.activeRole !== 'lead' ? `${roleLabel(task.activeRole)} ${task.shadowing ? 'shadowing' : 'working'}` : 'working' : task.state;
794
+ const clockTime = value => new Date(value).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
795
+ // A focused tab or button is filled with the accent; the current one is accent text.
796
+ function styleButton(widget, active, mark = active) {
797
+ const focused = screen.focused === widget, row = widget.position.top === 0;
798
+ Object.assign(widget.style, { fg: focused ? shade('background') : active ? accent : muted, bg: focused ? accent : active && row ? shade('background') : row ? shade('raised') : shade('background'), bold: active || focused, underline: mark && !focused, inverse: focused && !colour });
799
+ }
800
+ function placeRow(widgets, left) {
801
+ let x = left;
802
+ for (const widget of widgets) { widget.hidden = false; Object.assign(widget, { left: x, width: widget.content.length }); x += widget.content.length + 1; }
803
+ return x;
804
+ }
805
+ // --- Kanban: what teammates are working on in this project ---
806
+ // Each teammate's machine tells the Tasks its sessions work on in a project shared with a
807
+ // team (lib/peer/plans.mjs). They show under the four columns, by teammate, apart from this
808
+ // machine's cards and read-only. What a teammate sent is text to show, never to act on.
809
+ const teamTaskState = task => task.state === 'done' ? 'done' : 'in progress';
810
+ const teamTaskDone = task => ({ done: task.subtasks.filter(step => step.state === 'done').length, of: task.subtasks.length });
811
+ const teamTaskAt = task => task.updatedAt ?? task.doneAt ?? task.approvedAt;
812
+ function drawTeamTasks(on) {
813
+ const root = on && peerData.on ? controller.repo.project ?? controller.repo.root : null;
814
+ if (!root) teamTasks = { at: 0, root: null, people: [], sharing: 'none' };
815
+ else if (teamTasks.root !== root || Date.now() - teamTasks.at > 3000) {
816
+ let sharing = 'none'; try { sharing = facade?.taskSharing?.(root) ?? 'none'; } catch { sharing = 'none'; }
817
+ teamTasks = { at: Date.now(), root, people: peerList('teammateTasks', root).filter(person => Array.isArray(person?.tasks) && person.tasks.length), sharing };
818
+ }
819
+ const kept = teamTaskRows[kanbanTeamList.selected ?? 0]?.key, rows = teamTasks.people.flatMap(person => [{ person }, ...person.tasks.map(task => ({ person, task, key: `${person.peer}/${task.id}` }))]);
820
+ const note = !rows.length && teamTasks.sharing === 'no-origin' ? 'Tasks are not shown to teammates: this project has no origin remote.' : '';
821
+ const g = geometry(screen.width, screen.height), height = g.statusTop - g.top, tall = rows.length ? Math.min(rows.length + 2, Math.max(4, Math.floor(height / 3))) : note ? 3 : 0;
822
+ teamTaskRows = rows;
823
+ if (on) kanbanBoxes.forEach(frame => { frame.height = height - tall; });
824
+ Object.assign(kanbanTeam, { top: g.top + height - tall, left: 0, width: g.width, height: Math.max(3, tall) });
825
+ kanbanTeam.hidden = !tall; kanbanTeamList.hidden = !rows.length;
826
+ kanbanTeam.style.border.fg = screen.focused === kanbanTeamList ? accent : shade('border');
827
+ kanbanTeam.setLabel(rows.length ? ` Teammates' Tasks ${rows.filter(row => row.task).length} · read-only ` : ' Teammates ');
828
+ kanbanTeam.setContent(note);
829
+ Object.assign(kanbanTeamList.style.selected, screen.focused === kanbanTeamList ? selection : { fg: shade('text'), bg: shade('panel'), inverse: false, bold: false, underline: false });
830
+ const width = Math.max(20, g.width - 6);
831
+ const lines = rows.map(({ person, task }) => {
832
+ if (!task) return ink(`${person.online ? '●' : '○'} ${clip(person.name, 40)}`, person.online ? 'text' : 'muted', colour, true) + ink(person.online ? ' · online' : ` · offline · last heard ${relativeTime(person.heardAt)}`, person.online ? 'muted' : 'orange', colour);
833
+ const { done, of } = teamTaskDone(task), at = teamTaskAt(task);
834
+ const detail = [`${task.priority} · ${task.severity}`, teamTaskState(task), ...(of ? [`${done}/${of} done`] : []), task.who, ...(at ? [relativeTime(at)] : [])].join(' · '), title = clip(task.title, Math.max(12, width - 4 - detail.length));
835
+ return ` ${ink(title, task.state === 'done' || !person.online ? 'muted' : 'blue', colour, task.state !== 'done')}${ink(` ${clip(detail, Math.max(8, width - 4 - title.length))}`, 'muted', colour)}`;
836
+ });
837
+ if (lines.join('\n') !== kanbanTeamList.ritems?.join('\n')) kanbanTeamList.setItems(lines);
838
+ const found = kept === undefined ? -1 : rows.findIndex(row => row.key === kept);
839
+ kanbanTeamList.select(found >= 0 ? found : Math.max(0, Math.min(kanbanTeamList.selected ?? 0, rows.length - 1)));
840
+ // The Tasks it was on are gone: focus goes back to the columns.
841
+ if (on && !rows.length && screen.focused === kanbanTeamList) (lastPane.kanban ?? kanbanLists[0]).focus();
842
+ }
843
+ // Moves the selection between Tasks, over the rows that only name a teammate. False at an end.
844
+ function teamTaskStep(step) {
845
+ const at = teamTaskRows.map((row, index) => row.task ? index : -1).filter(index => index >= 0), here = at.indexOf(kanbanTeamList.selected ?? 0), next = here < 0 ? (step < 0 ? -1 : 0) : here + step;
846
+ if (!at.length || next < 0 || next >= at.length) return false;
847
+ kanbanTeamList.select(at[next]); return true;
848
+ }
849
+ // The whole Task, to read: description, goal, objectives with the met ones marked, subtasks.
850
+ async function openTeamTask() {
851
+ const row = teamTaskRows[kanbanTeamList.selected ?? 0]; if (!row?.task) return;
852
+ const { task, person } = row, width = Math.min(88, screen.width - 4) - 6, lines = [], { done, of } = teamTaskDone(task);
853
+ const say = (text, tone = 'text', bold = false) => { for (const line of wrapText(text, width)) lines.push(ink(line, tone, colour, bold)); };
854
+ say(task.title, 'text', true);
855
+ say(`${teamTaskState(task)} · priority ${task.priority} · severity ${task.severity}${task.revision > 1 ? ` · revision ${task.revision}` : ''}`, 'muted');
856
+ say(`Worked on by ${task.who} on ${person.name}'s machine · started by ${task.startedBy === 'schedule' ? 'a schedule' : task.startedBy === 'handoff' ? 'a handoff' : 'its owner'}`, 'muted');
857
+ say([task.approvedAt && `approved ${relativeTime(task.approvedAt)}`, task.doneAt && `done ${relativeTime(task.doneAt)}`, teamTaskAt(task) && `updated ${relativeTime(teamTaskAt(task))}`, !person.online && `last heard ${relativeTime(person.heardAt)}`].filter(Boolean).join(' · '), 'muted');
858
+ if (task.goal) { lines.push(''); say('Goal', 'muted', true); say(task.goal); }
859
+ if (task.description) { lines.push(''); say('Description', 'muted', true); say(task.description); }
860
+ lines.push(''); say(`Objectives · ${task.met.length} of ${task.objectives.length} met`, 'muted', true);
861
+ task.objectives.forEach((text, index) => { const met = task.met.includes(index + 1); say(` ${met ? '✓' : '·'} ${text}`, met ? 'green' : 'text'); });
862
+ if (of) { lines.push(''); say(`Subtasks · ${done} of ${of} done`, 'muted', true); for (const step of task.subtasks) say(` ${step.state === 'done' ? '✓' : step.state === 'doing' ? '▸' : '·'} ${step.text}`, step.state === 'done' ? 'green' : step.state === 'doing' ? 'blue' : 'text'); }
863
+ await listDialog(clip(`${person.name}'s Task`, 60), lines, { hint: 'read-only · ↑↓ scroll · esc close' });
864
+ }
865
+ function draw(idle) {
866
+ if (closing || drawing) return;
867
+ drawing = true;
868
+ try {
869
+ if (minimumSize()) { screen.render(); lastFrame = null; return; }
870
+ if (view === 'chat' && !controller) view = 'tasks';
871
+ const g = geometry(screen.width, screen.height), W = g.width, now = Date.now();
872
+ snapshot = view === 'chat' || view === 'kanban' && controller ? controller.snapshot() : allProjects(dashboardSnapshot());
873
+ adoptHistory(); follow();
874
+ peerData = peerFrame();
875
+ // In every view, a session set to Full access while its turn asks is answered for the owner.
876
+ let everyProject = null;
877
+ const asking = pendingNow().filter(row => {
878
+ const list = snapshot.tasks && (!row.root || row.root === controller?.repo.root) ? snapshot.tasks : (snapshot.projects ?? (everyProject ??= allProjects().projects)).find(project => project.root === row.root)?.tasks;
879
+ const task = (list ?? []).find(value => String(value.id) === String(row.taskId));
880
+ return !allowForFull(task, [row], task ? turnMode(task) : null);
881
+ });
882
+ approvals = view === 'tasks' || view === 'chat' ? asking : [];
883
+ // Sessions: the list's rows, the cursor, and what its row shows on the right.
884
+ dash = listed() ? view === 'tasks' ? snapshot : allProjects(dashboardSnapshot()) : null;
885
+ if (dash) { const was = view, owner = controller; syncSessions(now); if (view !== was || controller !== owner) snapshot = view === 'chat' ? controller.snapshot() : dash; }
886
+ if (sided !== listed()) layoutSession(g);
887
+ // Row 0: brand, tabs, then peers and the clock. The brand names the open project.
888
+ const name = brandName(), brandText = ` ◆ ${name} `;
889
+ if (brand.content !== brandText) { brand.setContent(brandText); brand.width = brandText.length; }
890
+ if (screen.title !== name) screen.title = name;
891
+ placeRow(tabs, brandText.length + 1);
892
+ for (const tab of tabs) styleButton(tab, tab === activeTab());
893
+ const stopped = peerData.on && peerData.status?.running === false;
894
+ const pillText = `${ink(`${peerData.on && !stopped ? '●' : '○'} ${peerSummary(peerData.on, peerData.status)}`, stopped ? 'orange' : peerData.on ? 'green' : 'muted', colour)}${ink(' · ', 'dim', colour)}${ink(clockTime(now), 'muted', colour)} `;
895
+ const pillWidth = visible(pillText).length, tabsEnd = tabs.at(-1).left + tabs.at(-1).width;
896
+ pills.setContent(pillText); Object.assign(pills, { left: Math.max(0, W - pillWidth), width: pillWidth });
897
+ pills.hidden = tabsEnd + 2 + pillWidth > W;
898
+ // Row 1: the view's own buttons, then its title.
899
+ for (const widget of [newCard, newBot, newProject, back]) widget.hidden = true;
900
+ const end = placeRow(subButtons(), 1);
901
+ for (const widget of [newCard, newBot, newProject]) styleButton(widget, true, false);
902
+ styleButton(back, false);
903
+ const task = view === 'chat' && snapshot.tasks.find(row => row.id === selectedId);
904
+ const readOnly = claim.readOnly ? ' [READ ONLY]' : '', goal = task ? firstWords(taskTitle(task), 60) : '';
905
+ const titled = (name, text) => [name, task ? text : selectedRequest ? 'saved request' : following ? following.bot ? `sending to @${following.bot}` : 'sending' : 'New session', task ? state(task) : ''].filter(Boolean).join(' · ') + readOnly;
906
+ // The session's badge: Ask, Auto or Full (orange), then Here when it works in your checkout.
907
+ const mode = task && MODES.includes(task.permissions) ? task.permissions : null;
908
+ const badge = mode ? ink(' · ', 'dim', colour) + ink(BADGES[mode], mode === 'full' ? 'orange' : 'muted', colour, true) + (task.place?.kind === 'here' ? ' ' + ink('Here', 'orange', colour, true) : '') : '';
909
+ // The title sits over the session: beside the list in Sessions, else after the view's buttons.
910
+ // Beside the list it is cut to fit. The project goes first, since the brand names it, then the end of the request.
911
+ // The request gets the room the dot and the state leave: titled('', '.') is those and one character.
912
+ // After the badge, a bot of this session that runs on a schedule: "◷ @elu every 1h · next in 52m".
913
+ // It is left out when the line has no room for it beside a readable title.
914
+ const runs = task ? (timedSessions()[snapshot.root]?.[task.id] ?? []).map(value => botScheduleBrief(value)).join(' · ') : '', clocked = runs ? ink(' · ', 'dim', colour) + ink(`◷ ${runs}`, 'blue', colour) : '';
915
+ const left = listed() ? boardBox.width + 1 : end + 1, tail = clocked && W - left - 1 - visible(badge).length - visible(clocked).length >= 24 ? clocked : '', room = Math.max(1, W - left - 1 - visible(badge).length - visible(tail).length);
916
+ const title = view === 'kanban' ? controller ? snapshot.name + readOnly : '' : view !== 'chat' ? '' : !listed() || titled(snapshot.name, goal).length <= room ? titled(snapshot.name, goal) : titled('', goal).length <= room ? titled('', goal) : titled('', fitWords(goal, Math.max(4, room - titled('', '.').length + 1)));
917
+ Object.assign(header, { left, width: Math.max(1, W - left - 1) });
918
+ header.setContent(ink(clip(title, room), 'text', colour, true) + badge + tail);
919
+ // Sessions: the list. Its rows are built before this, with the cursor.
920
+ boardBox.hidden = !listed();
921
+ if (listed()) {
922
+ // The list's last column is its scrollbar. The first one marks the cursor's row.
923
+ // A row stops one column short of the list, as in the other lists. One that fills it is wrapped by blessed and loses its age.
924
+ const width = Math.max(8, board.width - 2), mark = screen.focused === board ? 'orange' : 'dim', columns = barColumns(boardLines.flatMap(line => line.row ? [line.row] : []), width - 1);
925
+ const lines = boardLines.map((line, place) => line.spacer ? '' : line.note ? ink(` ${line.note}`, 'muted', colour) : line.header
926
+ ? ink(` ${line.header.toUpperCase()}`, line.tone, colour, true) + ink(` ${line.count} `, 'muted', colour) + ink('─'.repeat(Math.max(0, width - line.header.length - String(line.count).length - 3)), 'dim', colour)
927
+ : ink(place === barAt ? '▌' : ' ', mark, colour) + (line.add ? ink('+ New session', 'orange', colour, true) : barCells(line.row, width - 1, now, columns).map(cell => ink(cell.text, cell.tone, colour, cell.bold, { trusted: line.row.kind === 'peer' && line.row.item?.trusted === true })).join('')));
928
+ if (lines.join('\n') !== board.ritems?.join('\n')) board.setItems(lines);
929
+ board.select(barAt);
930
+ }
931
+ // Chats
932
+ teamOn = view === 'chats' && peerData.on;
933
+ teamOff.hidden = view !== 'chats' || teamOn;
934
+ teamOff.setContent(facade ? stopped ? PEER_STOPPED : PEER_OFF : PEER_MISSING);
935
+ teamBoxes.forEach((frame, index) => { frame.hidden = !teamOn; frame.style.border.fg = screen.focused === teamLists[index] ? accent : shade('border'); });
936
+ if (teamOn) {
937
+ // The bar lists channels, then each teammate with their live agents. Each pane keeps its selected row while content moves.
938
+ const widths = teamLists.map(list => Math.max(8, list.width - 2)), sizes = [widths[0], widths[0] - 2, widths[1]], [bar, work] = teamPanes(peerData, sizes, colour);
939
+ const mates = bar.flatMap(row => [row, ...work.filter(item => item.value?.row && row.value?.type === 'member' && item.value.peer === row.value.key).map(item => ({ ...item, line: ` ${item.line}` }))]);
940
+ const place = (index, rows, first = 0) => {
941
+ const list = teamLists[index], kept = teamRows[index][list.selected ?? 0]?.key, lines = rows.map(row => row.line);
942
+ teamRows[index] = rows;
943
+ if (lines.join('\n') !== list.ritems?.join('\n')) list.setItems(lines);
944
+ const found = kept === undefined ? -1 : rows.findIndex(row => row.key === kept);
945
+ list.select(found >= 0 ? found : kept === undefined ? first : Math.min(list.selected ?? 0, rows.length - 1));
946
+ };
947
+ // The bar opens on the channel or person open last, else the first with a thread that waits for the owner, else the first channel. A new channel takes the cursor once.
948
+ const jump = chatJump === null ? -1 : mates.findIndex(row => row.value?.target === chatJump); chatJump = null;
949
+ const start = [row => row.value?.target === chatTarget, row => row.value?.waiting > 0, row => row.value?.target].map(match => mates.findIndex(match)).find(index => index >= 0);
950
+ place(0, mates, start ?? 0);
951
+ if (jump >= 0) teamLists[0].select(jump);
952
+ // The row under the cursor is the open channel or person (an agent's row opens its teammate); a heading or an add row keeps the last one.
953
+ const pointed = mates[teamLists[0].selected ?? 0]?.value, wanted = pointed?.target ?? (pointed?.type === 'agent' ? dmTarget(pointed.peer) : chatTarget);
954
+ chatOpen = mates.find(row => row.value?.target === wanted)?.value ?? mates.find(row => row.value?.target)?.value ?? null;
955
+ chatTarget = chatOpen?.target ?? null;
956
+ const where = chatOpen ? chatOpen.type === 'room' ? `#${chatOpen.name}` : chatOpen.type === 'bot' ? `@${chatOpen.name}${chatOpen.peer ? ` · ${botHome(chatOpen)}` : ''}` : peerName(chatOpen.name, chatOpen.key) : null;
957
+ place(1, teamPanes(peerData, sizes, colour, chatTarget, where)[2]);
958
+ teamBoxes[1].setLabel(` ${where ?? 'Threads'} `);
959
+ // A channel names who is in it on a line of its own, and its threads start under that line.
960
+ const inside = chatOpen?.type === 'room' ? membersLine(chatOpen, widths[1], colour) : '';
961
+ chatMembers.setContent(inside); chatMembers.hidden = !inside;
962
+ Object.assign(teamLists[1], inside ? { top: 2, height: '100%-3' } : { top: 1, height: '100%-2' });
963
+ layoutChats(g);
964
+ }
965
+ // Kanban
966
+ const kanbanOn = view === 'kanban' && Boolean(controller);
967
+ kanbanOff.hidden = view !== 'kanban' || kanbanOn;
968
+ kanbanOff.setContent('Kanban shows one project. Open one in the Projects tab, or start elu inside it.');
969
+ kanbanBoxes.forEach((frame, index) => { frame.hidden = !kanbanOn; frame.style.border.fg = screen.focused === kanbanLists[index] ? accent : shade('border'); });
970
+ if (kanbanOn) {
971
+ let data = readBoard(controller.repo);
972
+ if (data.cards.some(card => card.requestId && !card.taskId) && linkCards(controller.repo, snapshot.queue ?? [])) data = readBoard(controller.repo);
973
+ const previous = kanbanData;
974
+ kanbanData = boardColumns(data, snapshot, now);
975
+ kanbanData.forEach((column, index) => {
976
+ const list = kanbanLists[index], width = Math.max(4, list.width - 2), kept = previous[index]?.items[list.selected ?? 0]?.key;
977
+ kanbanBoxes[index].setLabel(` ${column.name} ${column.items.length} `);
978
+ const tone = item => item.task?.state === 'needs_input' || item.request?.state === 'blocked' || item.task?.state === 'blocked' || item.plan?.state === 'proposed' ? 'orange' : column.column === 'progress' ? 'blue' : column.column === 'done' ? 'muted' : 'text';
979
+ const lines = column.items.map(item => {
980
+ // A Task's card is its title first; where the Task stands goes beside it as far as it fits (lib/kanban.mjs).
981
+ const card = item.plan ? fitCard(plain(item.title), item.detail, width) : null, title = card ? card.title : fitWords(plain(item.title), width), room = width - title.length - 2;
982
+ const detail = card ? card.detail : item.detail && room >= Math.min(item.detail.length, 12) ? clip(item.detail, room) : '';
983
+ return ink(title, tone(item), colour, column.column !== 'done') + (detail ? ink(` ${detail}`, 'muted', colour) : '');
984
+ });
985
+ if (lines.join('\n') !== list.ritems?.join('\n')) list.setItems(lines);
986
+ const found = kept === undefined ? -1 : column.items.findIndex(item => item.key === kept);
987
+ list.select(found >= 0 ? found : Math.max(0, Math.min(list.selected ?? 0, column.items.length - 1)));
988
+ });
989
+ }
990
+ drawTeamTasks(kanbanOn);
991
+ // Bots
992
+ botsBox.hidden = view !== 'bots';
993
+ if (view === 'bots') drawBots();
994
+ // Projects
995
+ projectsBox.hidden = view !== 'projects';
996
+ if (view === 'projects') drawProjects();
997
+ // Usage
998
+ usageBox.hidden = view !== 'usage';
999
+ if (view === 'usage') drawUsage(now);
1000
+ // A task's chat, or the New task form.
1001
+ const form = composing();
1002
+ activity.hidden = view === 'chat' ? form : view !== 'tasks'; compose.hidden = view !== 'chat' || !form;
1003
+ if (form) {
1004
+ composeMode ??= defaultMode();
1005
+ // A row is a name, a value and a hint. Beside the list the pane is narrow: the value
1006
+ // column shrinks, then a shorter hint is used, then none, so no row wraps.
1007
+ const room = Math.max(30, composeList.width - 1), column = Math.min(22, Math.max(12, room - 29));
1008
+ const label = text => ink(text.padEnd(13), 'muted', colour);
1009
+ const row = (name, value, tone, ...hints) => {
1010
+ const text = clip(value, Math.min(40, room - 13)), wide = Math.max(column, text.length + 2), hint = hints.find(hint => 13 + wide + hint.length <= room);
1011
+ // tone: a colour for the whole value, or a function that paints it word by word.
1012
+ if (typeof tone === 'function') return label(name) + tone(text) + ' '.repeat(hint ? wide - text.length : 0) + ink(hint ?? '', 'muted', colour);
1013
+ return label(name) + ink(hint ? text.padEnd(wide) : text, tone, colour, true) + ink(hint ?? '', 'muted', colour);
1014
+ };
1015
+ composeRows = [{ type: 'project', line: label('Project') + ink(clip(snapshot.name, room - 13 - (claim.readOnly ? 11 : 0)), 'text', colour, true) + ink(claim.readOnly ? ' read only' : '', 'orange', colour) }];
1016
+ const upcoming = composeWhen && nextRun(composeWhen, new Date(now));
1017
+ composeRows.push({ type: 'when', line: row('When', composeWhen?.label ?? 'now', composeWhen ? 'orange' : 'text', ...(composeWhen ? [upcoming ? `next ${dateText(upcoming)}` : 'has passed'] : ['/schedule to run it later', '/schedule'])) });
1018
+ // The session's permissions: the mode, where the lead works, extra folders it may read.
1019
+ composeRows.push({ type: 'permissions', line: row('Permissions', LABELS[composeMode], composeMode === 'full' ? 'orange' : 'text', '/permissions ask, auto or full', '/permissions') });
1020
+ composeRows.push({ type: 'place', line: row('Runs in', placeText(composePlace), composePlace.kind === 'here' ? 'orange' : 'text', '/place worktree, branch NAME or here', '/place') });
1021
+ const folders = composeFolders.length ? composeFolders.map(shortHome).join(' ') : 'none';
1022
+ composeRows.push({ type: 'folders', line: row('Folders', folders, 'text', ...(composeFolders.length ? ['/permissions folders add PATH · x clears', '/permissions folders add PATH', 'x clears'] : ['/permissions folders add PATH', '/permissions folders'])) });
1023
+ // The model the message goes to, then the bots that review its changes.
1024
+ const using = nameOf(composeModel ?? controller.defaultModel());
1025
+ composeRows.push({ type: 'model', line: row('Model', using, 'orange', '/model NAME, or /model to pick', '/model NAME') });
1026
+ composeRows.push({ type: 'bots', line: row('Bots', composeBots.length ? botNames(composeBots, Math.min(40, room - 13)) : 'none', text => paintBots(text, 'text'), '/bots add NAME, /bots remove NAME', '/bots add NAME') });
1027
+ const lines = composeRows.map(row => row.line);
1028
+ if (lines.join('\n') !== composeList.ritems?.join('\n')) composeList.setItems(lines);
1029
+ // The note under the rows takes the lines it needs, and the rows take the rest.
1030
+ const note = (composeWhen ? `Saved as a schedule: each run starts a new session with ${using}.\nRuns while this computer is awake, even with ELU Code closed.` : `Your message goes to ${using}.\nBots review the changes. Type @name to add one or to write to it.`).split('\n').flatMap(line => wrapText(line, Math.max(10, compose.width - 4)));
1031
+ if (composeHint.height !== note.length) { composeHint.height = note.length; composeList.height = `100%-${note.length + 3}`; }
1032
+ composeHint.setContent(note.join('\n'));
1033
+ compose.style.border.fg = screen.focused === composeList ? accent : shade('border');
1034
+ }
1035
+ const lines = [];
1036
+ // A plain line in the chat is wrapped at words here, so the pane beside the list never wraps one a second time.
1037
+ const note = (text, tone, bold = false) => wrapText(text, chatWidth()).map(row => ink(row, tone, colour, bold)).join('\n');
1038
+ if (task) {
1039
+ const entries = conversationEntries(repo, task), leadName = modelOf(task);
1040
+ // Your messages sit on a shaded band behind a blue bar; the lead's behind an orange one.
1041
+ // A question ends the lead's last reply, in the same block.
1042
+ const waiting = task.state === 'needs_input' ? task.reply?.outcome === 'needs_input' ? task.reply : task.result : null;
1043
+ const asked = String(waiting?.question ?? '').trim(), choices = (waiting?.choices ?? []).map((choice, index) => `${index + 1}. ${choice}`).join('\n');
1044
+ const withQuestion = text => [text, asked && !text.includes(asked) ? asked : '', choices].filter(Boolean).join('\n\n');
1045
+ const last = waiting && entries.at(-1)?.lead ? entries.at(-1) : null;
1046
+ for (const entry of entries) {
1047
+ const text = entry === last ? withQuestion(entry.text) : entry.text;
1048
+ // A ! command sits between the messages behind a green bar, with its output as printed.
1049
+ if (entry.shell) { lines.push(chatMessage({ title: entry.role, at: entry.at, text, tone: 'green', raw: true }, chatWidth(), colour)); continue; }
1050
+ // A bot's reply sits behind a bar in the bot's own colour, never the lead's orange or your blue; one it could not give, behind a red one.
1051
+ if (entry.bot) { lines.push(chatMessage({ title: entry.role, at: entry.at, text, tone: entry.failed ? 'red' : entry.quiet ? 'muted' : toneOfBot(entry.bot) }, chatWidth(), colour)); continue; }
1052
+ lines.push(chatMessage({ title: entry === last ? `${entry.role} is waiting for you` : entry.role, at: entry.at, text, tone: entry.lead ? 'orange' : 'blue', shaded: !entry.lead }, chatWidth(), colour));
1053
+ }
1054
+ if (waiting && !last) lines.push(chatMessage({ title: `${leadName} is waiting for you`, text: withQuestion(''), tone: 'orange' }, chatWidth(), colour));
1055
+ // While it waits for an allow or deny it is not working: the approval below says what it waits for.
1056
+ if (task.state === 'running' && !approvalsFor(task).length) {
1057
+ lines.push(ink(task.shadowing ? `${roleLabel(task.activeRole)} is shadowing the last turn` : task.activeRole === 'checks' ? 'Checks are running' : `${!task.activeRole || task.activeRole === 'lead' ? leadName : roleLabel(task.activeRole)} is working`, botOfKey(task.activeRole) ? toneOfBot(botOfKey(task.activeRole)) : 'orange', colour, true));
1058
+ const event = snapshot.activity.findLast(row => String(row.taskId) === String(task.id)); if (event) lines.push(ink(clip(event.text, chatWidth()), 'muted', colour));
1059
+ lines.push(ink('type a message any time · /stop to stop', 'muted', colour));
1060
+ }
1061
+ if (task.state === 'unverified') lines.push(note('Edits are not verified. /publish runs the checks first.', 'orange'));
1062
+ if (task.state === 'ready') lines.push(note('Checks complete. /changes to inspect · /publish to review push', 'green'));
1063
+ if (task.error) lines.push(note(task.error.message, 'orange'));
1064
+ // The session's Task (lib/plan.mjs): what waits for a yes, or what is being worked on and how far it is.
1065
+ // A rejected one is a single line, until the chat goes on.
1066
+ const dropped = task.plan?.state === 'rejected' && (task.messages ?? []).some(row => Date.parse(row.at) > Date.parse(task.plan.rejectedAt));
1067
+ if (task.plan && !dropped) lines.push(taskCard(task.plan, { width: chatWidth(), colour, approval: approvalsFor(task).length > 0 }));
1068
+ // A live approval waits here and under Needs you; Esc leaves the box, then y or n answers.
1069
+ for (const row of approvalsFor(task)) lines.push([ink('Waiting for your approval', 'orange', colour, true), ink(`${!row.role || row.role === 'lead' ? leadName : roleLabel(row.role)} wants to:`, 'text', colour), note(row.summary, 'text', true), ink(offersFull(task, row) ? `Esc, then ${approvalKeys(task, row)}` : 'Esc, then y allow · n deny · esc later', 'muted', colour)].join('\n'));
1070
+ // Actions the vendor refused and reported, and a mode the vendor did not give.
1071
+ const lastLead = task.runs.findLast(run => run.role === 'lead');
1072
+ if (lastLead?.permissions === 'auto' && lastLead.effectivePermissions === 'ask') lines.push(note('Asked instead. Approve for me is not available here.', 'muted'));
1073
+ // A second launch for one message is said. One that started the message over may have run again
1074
+ // what the first one ran; one that asked the same session for its reply again started nothing over.
1075
+ if (lastLead?.again) lines.push(note(lastLead.resumed ? `${leadName} was asked for its reply again: the first one was not accepted.` : `${leadName} was started twice for this message: its first reply was not accepted. Anything it ran may have run twice.`, 'muted'));
1076
+ // A step in which a never-list command was refused shows whatever the turn went on to say.
1077
+ const refused = Array.isArray(lastLead?.denials) ? lastLead.denials.filter(denial => task.state === 'needs_input' || denial?.never) : [];
1078
+ if (refused.length) {
1079
+ const asked = (task.approvals ?? []).filter(row => row.role === 'lead' && row.session === lastLead.session), rows = [...new Map(refused.map(denial => blockedLine(denial, lastLead, asked)).map(row => [row.text, row])).values()].slice(0, 3);
1080
+ lines.push([...rows.map(row => note(`Blocked: ${row.text}.`, 'orange')), ...(rows.some(row => row.lifts) ? [ink('/permissions to allow more, then reply.', 'muted', colour)] : [])].join('\n'));
1081
+ }
1082
+ // What was sent while the session worked waits here and goes by itself when the turn
1083
+ // ends. The newest one names the key that takes it back, and says that an approval
1084
+ // above is not answered by it.
1085
+ const held = (snapshot.queue ?? []).filter(row => row.continuationTaskId === task.id && ['queued', 'starting'].includes(row.state)), newest = waitingMessage(task.id, held);
1086
+ for (const row of held) {
1087
+ const when = row.state === 'starting' ? 'Starting.' : ['running', 'preparing'].includes(task.state) ? 'Sent when this turn ends.' : row.reason ?? 'Waiting to start.';
1088
+ const more = row === newest ? [when, '↑ in the message box takes it back', ...(approvalsFor(task).length ? ['The approval above still needs your answer.'] : [])] : [when];
1089
+ lines.push(chatMessage({ title: row.bot ? `${row.scheduled ? 'Schedule' : 'You'} → @${row.bot} (waiting)` : 'You (waiting)', at: row.createdAt, text: row.goal, tone: 'blue', shaded: true }, chatWidth(), colour) + '\n' + more.map(text => note(text, 'muted')).join('\n'));
1090
+ }
1091
+ }
1092
+ // A ! command still running in this session shows the end of its output so far.
1093
+ if (task && shellRun?.session?.id === task.id && shellRun.session.repo.root === repo.root) lines.push(chatMessage({ title: 'Shell', at: shellRun.at, text: [`$ ${shellRun.command}`, shellRun.run.tail(2000).trimEnd()].filter(Boolean).join('\n'), tone: 'green', raw: true }, chatWidth(), colour) + '\n' + ink('Running. /stop ends it.', 'muted', colour));
1094
+ const current = selectedRequest && (snapshot.queue ?? []).find(row => row.id === selectedRequest.id);
1095
+ if (current && ['queued', 'starting', 'running'].includes(current.state)) lines.push(chatMessage({ title: current.bot ? `${current.scheduled ? 'Schedule' : 'You'} → @${current.bot} (queued)` : 'You (queued)', at: current.createdAt, text: current.goal, tone: 'blue', shaded: true }, chatWidth(), colour) + '\n' + note(`${current.reason ?? 'Waiting to start.'} /stop cancels it.`, 'muted'));
1096
+ else if (selectedRequest) lines.push(chatMessage({ title: selectedRequest.bot ? `Saved request → @${selectedRequest.bot}` : 'Saved request', at: selectedRequest.createdAt, text: selectedRequest.goal, tone: 'blue', shaded: true }, chatWidth(), colour) + '\n' + note([(current ?? selectedRequest).state === 'blocked' && (current ?? selectedRequest).reason, 'Send it to retry. /stop cancels it.'].filter(Boolean).join(' '), 'muted'));
1097
+ // Asked before the new text is set: lines that arrive below must not read as scrolling up.
1098
+ const chatNow = view === 'chat' ? `${controller?.repo.root ?? ''}\0${selectedId ?? selectedRequest?.id ?? following?.id ?? ''}` : null;
1099
+ const held = chatNow !== null && chatNow === chatShown && scrolledUp(activity);
1100
+ chatShown = chatNow;
1101
+ if (view === 'chat') activity.setContent(lines.join('\n\n'));
1102
+ // Sessions, on a row that is not a session: the row's own text, and how to start when there is nothing yet.
1103
+ else if (view === 'tasks') {
1104
+ // A session of another project shows once that project is the active one. Until then the pane is empty.
1105
+ const row = boardLines[barAt]?.row, card = row && !sessionOf(row) && rowCard(row, now);
1106
+ activity.setContent(row && !card ? '' : card ? `${chatBlock({ title: card.title, text: blockText(card.text, { trusted: row.kind === 'peer' && row.item?.trusted === true }), tone: card.tone, width: chatWidth(), colour })}\n\n${ink(keyRows(card.keys, chatWidth()).join('\n'), 'muted', colour)}`
1107
+ : note(dash.projects.length ? 'Type below to start a session, or press n to set it up first.' : 'No project yet. Press n to create one here. n in the Projects tab opens another folder.', 'muted'));
1108
+ }
1109
+ // The message box says where the text goes, and which bots are in the chat.
1110
+ const target = sessionBox() && goalTarget(), inChat = task ? botsOf(task) : form ? composeBots : [], withBots = inChat.length ? ` · with ${botNames(inChat)}` : '';
1111
+ // The model the text goes to: the open session's, else the one a new session starts with.
1112
+ // Text that starts with a saved bot's @name is written to that bot.
1113
+ const toModel = !controller ? null : task ? modelOf(task) : nameOf((form ? composeModel : !sessionBox() && selectedRequest?.model) || controller.defaultModel());
1114
+ const toBot = (target || view === 'chat' && !claim.readOnly) && ask.getValue().trimStart().startsWith('@') ? botMessage(ask.getValue(), savedBots())?.bot.name ?? null : null;
1115
+ // A teammate's @name, when no bot has it, makes the text a question to them.
1116
+ const toPerson = !toBot && (sessionBox() || view === 'chat') && ask.getValue().trimStart().startsWith('@') ? teammateNamed(ask.getValue().trim().split(/\s+/)[0])?.name ?? null : null;
1117
+ // With no @name the text goes to the bot the owner last wrote to in this session; @model goes to the model.
1118
+ const stays = !toBot && !toPerson && task && view === 'chat' && !selectedRequest && !claim.readOnly && !TO_MODEL.test(ask.getValue().trimStart()) ? addressedBot(task, snapshot.queue ?? []) : null;
1119
+ // Beside the list the box is narrow: the label gives up its bots, then its last part, before it would wrap into the text.
1120
+ const fits = (...parts) => { for (let count = parts.length; count > 1; count--) { const text = ` ${parts.slice(0, count).join('')} `; if (text.length <= ask.width - 4) return text; } return ` ${parts[0]} `; };
1121
+ // A message that starts with ! is a shell command; the label says where it runs.
1122
+ const said = (ask.getValue().trimStart().startsWith('!') ? ` Run in ${clip(shortHome(shellPlace().cwd), 40)} · Enter runs it `
1123
+ : view === 'chats' ? !chatOpen ? ' Ask your team, or @name one teammate ' : chatOpen.type === 'room' ? ` Message #${chatOpen.name} · the first teammate to take it answers ` : chatOpen.type !== 'bot' ? ` Message ${peerName(chatOpen.name, chatOpen.key)} ` : chatOpen.peer ? ` Message @${chatOpen.name} on ${botHome(chatOpen)} · runs after ${peerName(chatOpen.owner, chatOpen.peer)} accepts it ` : ` Message @${chatOpen.name} · it answers in its own session `
1124
+ : toBot ? ` Message @${toBot} `
1125
+ : stays ? fits(`Message @${stays}`, ` · @model writes to ${toModel}`)
1126
+ : toPerson ? ` Ask @${toPerson} · teammate `
1127
+ : sessionBox() ? target ? toModel ? fits(`New session → ${toModel}`, ` · ${projectName(target)}`) : ` New session · ${projectName(target)} ` : ' New session · Enter creates a project first '
1128
+ : view === 'kanban' ? controller ? ` Add a task to To do · ${snapshot.name} ` : ' Create a project first: n in the Projects tab '
1129
+ : claim.readOnly ? ' Read only · / for commands ' : composing() ? composeWhen ? fits(`Schedule → ${toModel}`, ` · ${composeWhen.label}`, ` · ${snapshot.name}`) : fits(`New session → ${toModel}`, ` · ${snapshot.name}`, withBots) : mode ? fits(`Message ${toModel}`, ` · ${LABELS[mode]}`, withBots) : fits(`Message ${toModel}`, withBots));
1130
+ // The label is plain text, but for the bots it names: each in its own colour.
1131
+ const named = withBots && said.endsWith(`${withBots} `) ? said.length - withBots.length - 1 : said.length;
1132
+ ask.setLabel(blessed.escape(said.slice(0, named)) + (named < said.length ? ` · with ${paintBots(botNames(inChat))} ` : ''));
1133
+ const typing = screen.focused === ask;
1134
+ ask.style.border.fg = typing ? accent : shade('border'); ask.style.label.fg = typing ? accent : muted;
1135
+ // Status row: the last message, else what the open task is doing. The key bar below never hides it.
1136
+ const card = kanbanLists.includes(screen.focused) && kanbanData[kanbanLists.indexOf(screen.focused)]?.items[screen.focused.selected ?? 0];
1137
+ const quiet = card ? [card.title, card.note ?? card.detail].filter(Boolean).join(' · ') : task ? [task.state === 'needs_input' ? 'waiting for your answer' : task.state, snapshot.requests?.length ? `${snapshot.requests.length} saved` : ''].filter(Boolean).join(' · ') : '';
1138
+ // Beside the list the row is short. A message too long for it takes a second row from the chat above.
1139
+ // A lead that waits for an allow or deny says so here first, with the keys before the
1140
+ // command so a narrow row still shows them. The last message goes below it, on two
1141
+ // rows when one is too short for it.
1142
+ const asked = task ? approvalsFor(task)[0] : null;
1143
+ const long = listed() && message.length + 2 > status.width;
1144
+ // With f the row is longer: where it would not fit, or would leave no room for what is asked, it starts at Esc.
1145
+ const askForms = asked && offersFull(task, asked) ? [`? Needs you · Esc, then ${approvalKeys(task, asked)}`, `? Esc, then ${approvalKeys(task, asked)}`] : ['? Needs you · Esc, then y allow · n deny'];
1146
+ const askKeys = askForms.find(form => status.width - form.length - 3 >= 12) ?? askForms.find(form => form.length <= status.width) ?? askForms.at(-1), askText = status.width - askKeys.length - 3 >= 12 && asked?.summary ? ` · ${asked.summary}` : '';
1147
+ if (asked) status.setContent([ink(clip(askKeys + askText, status.width), 'orange', colour, true), message ? ink(`› ${long ? fitWords(plain(message), status.width * 2 - 4) : clip(message, status.width - 2)}`, 'text', colour) : ''].filter(Boolean).join('\n'));
1148
+ else status.setContent(message ? ink(`› ${long ? fitWords(plain(message), status.width * 2 - 4) : clip(message, status.width - 2)}`, 'text', colour) : ink(clip(quiet, status.width), 'muted', colour));
1149
+ const tall = (asked ? 1 : 0) + (message ? long ? 2 : 1 : asked ? 0 : 1);
1150
+ if (status.height !== tall) { Object.assign(status, { top: g.statusTop - tall + 1, height: tall }); activity.height = compose.height = g.statusTop - g.top - tall + 1; }
1151
+ // A Task that waits for approval says so here as an approval does: first, with the last
1152
+ // message on a second row. An approval of an action in the same session comes before it.
1153
+ if (!asked && task?.plan?.state === 'proposed') {
1154
+ const two = message ? 2 : 1;
1155
+ status.setContent([ink(clip(taskNeedsYou(task.plan), status.width), 'orange', colour, true), message ? ink(`› ${clip(message, status.width - 2)}`, 'text', colour) : ''].filter(Boolean).join('\n'));
1156
+ if (status.height !== two) { Object.assign(status, { top: g.statusTop - two + 1, height: two }); activity.height = compose.height = g.statusTop - g.top - two + 1; }
1157
+ }
1158
+ drawSessionViews(task);
1159
+ keysBar.setContent(keyHints(keyPairs(), W - 2, colour));
1160
+ for (const list of [board, composeList, ...teamLists, botsList, projectsList, usageList]) Object.assign(list.style.selected, screen.focused === list ? selection : QUIET);
1161
+ for (const [panel, owner] of [[boardBox, board], [activity, activity], [botsBox, botsList], [projectsBox, projectsList], [usageBox, usageList]]) panel.style.border.fg = screen.focused === owner ? accent : shade('border');
1162
+ if (chatNow === null ? screen.focused !== activity : !held) activity.setScrollPerc(100);
1163
+ if (modal) modal.setFront();
1164
+ syncPalette();
1165
+ const next = frame(); if (idle !== true || next !== lastFrame) { lastFrame = next; screen.render(); }
1166
+ // view names the tab: Sessions is 'tasks' whatever shows beside its list, and 'chat' is a session opened from Kanban.
1167
+ // session is what shows as a chat: a session's number, 'new' for the form, 'request' for a message not started yet.
1168
+ onRender?.({ screen, selection: mouseSelection, snapshot, panels: { header, brand, pills, tabs, newCard, newBot, newProject, kanbanBoxes, kanbanLists, kanbanOff, kanbanTeam, kanbanTeamList, back, board, boardBox, compose, composeList, composeHint, activity, chat: activity, changes: changesBox, sessionTabs, status, keys: keysBar, palette, ask, teamBoxes, teamLists, teamOff, botsBox, botsList, projectsBox, projectsList, usageBox, usageList }, controller, view: listed() ? 'tasks' : view, session: view !== 'chat' ? null : selectedId ?? (composing() ? 'new' : 'request'), sessionView: changesBox.hidden ? 'chat' : 'changes' });
1169
+ } catch (error) { header.setContent(displayText(clip(error.message, screen.width))); screen.render(); }
1170
+ finally { drawing = false; }
1171
+ }
1172
+ // Bots: one row per saved bot. The selected bot keeps its row while the list changes; a
1173
+ // new one takes the cursor once.
1174
+ function drawBots() {
1175
+ const width = Math.max(20, botsList.width - 2), kept = botJump ?? botRows[botsList.selected ?? 0]?.name; botJump = null;
1176
+ let lines;
1177
+ try { botRows = listBots(); lines = botLines(botRows, width, colour, botModelName); }
1178
+ catch (error) { botRows = []; lines = [ink(` ${clip(error.message, width - 1)}`, 'orange', colour)]; }
1179
+ if (lines.join('\n') !== botsList.ritems?.join('\n')) botsList.setItems(lines);
1180
+ const found = kept === undefined ? -1 : botRows.findIndex(bot => bot.name === kept);
1181
+ botsList.select(found >= 0 ? found : Math.max(0, Math.min(botsList.selected ?? 0, botRows.length - 1)));
1182
+ }
1183
+ // Projects: one row per project: name, folder, sessions, last activity. The active one is
1184
+ // first and in the accent; the rest keep their order. The selection stays on its project
1185
+ // while the list changes.
1186
+ function drawProjects() {
1187
+ const width = Math.max(20, projectsList.width - 2), kept = projectRows[projectsList.selected ?? 0]?.root, now = Date.now();
1188
+ const top = controller?.repo.root;
1189
+ projectRows = [...snapshot.projects].sort((a, b) => (b.root === top) - (a.root === top));
1190
+ const lastActivity = project => {
1191
+ const times = [...project.activity.map(row => row.at), ...project.tasks.map(task => task.createdAt)].map(value => Date.parse(value)).filter(Number.isFinite);
1192
+ return times.length ? relativeTime(new Date(Math.max(...times)).toISOString(), now) : 'no activity';
1193
+ };
1194
+ const lines = projectRows.length ? projectRows.map(project => {
1195
+ // ● marks the active project: the one in the header, which new sessions and Kanban use.
1196
+ const active = project.root === controller?.repo.root, name = ink(`${active ? '●' : ' '} ${clip(project.name, 22).padEnd(24)}`, active ? 'orange' : 'text', colour, true);
1197
+ if (project.unavailable) return name + ink('unavailable', 'muted', colour);
1198
+ const sessions = project.tasks.filter(task => !project.closed?.includes(task.id)).length, tail = `${sessions} session${sessions === 1 ? '' : 's'}`.padEnd(13) + lastActivity(project);
1199
+ const room = Math.max(4, width - 26 - tail.length - 2);
1200
+ return name + ink(clip(shortHome(project.root), room).padEnd(room + 2), 'muted', colour) + ink(tail, 'muted', colour);
1201
+ }) : [ink(' No projects yet. n creates one.', 'muted', colour)];
1202
+ if (lines.join('\n') !== projectsList.ritems?.join('\n')) projectsList.setItems(lines);
1203
+ const found = kept === undefined ? -1 : projectRows.findIndex(project => project.root === kept);
1204
+ projectsList.select(found >= 0 ? found : Math.max(0, Math.min(projectsList.selected ?? 0, projectRows.length - 1)));
1205
+ }
1206
+ // Usage: one block per vendor, then today's spend. The data is read again when the saved
1207
+ // usage changes or a check finishes; the selected vendor keeps its row.
1208
+ const usageReaders = () => ({ env: usage.env, readFile: usage.readFile });
1209
+ const usageVendors = () => { if (usage.vendors) return usage.vendors(); try { return vendorList.kept() ?? vendorList.read(() => detect(controller?.repo.root ?? startDirectory)); } catch { return []; } };
1210
+ function drawUsage(now) {
1211
+ const key = JSON.stringify(snapshot.usage);
1212
+ if (!usageData || key !== usageKey) { usageKey = key; usageData = usageTab({ vendors: usageVendors(), env: usage.env, readFile: usage.readFile, now }); }
1213
+ const width = Math.max(20, usageList.width - 2), kept = usageRows[usageList.selected ?? 0]?.vendor;
1214
+ const total = todayCost(snapshot.projects, new Date(now));
1215
+ usageRows = [...usageBlocks(usageData, { now: new Date(now) }), { vendor: null, text: `ELU Code spend today ${total}`, tone: 'muted' }];
1216
+ const lines = usageRows.map(row => ink(clip(row.text, width), row.tone, colour, row.bold));
1217
+ if (lines.join('\n') !== usageList.ritems?.join('\n')) usageList.setItems(lines);
1218
+ const found = kept === undefined ? -1 : usageRows.findIndex(row => row.vendor === kept);
1219
+ usageList.select(found >= 0 ? found : Math.max(0, Math.min(usageList.selected ?? 0, usageRows.length - 1)));
1220
+ }
1221
+ // The tab's check: when it opens and the last one is older than two minutes, or on r.
1222
+ async function refreshUsageTab(force = false) {
1223
+ if (usageBusy || closing || (!force && !usageStale())) return;
1224
+ usageBusy = true; setMessage('Checking usage…');
1225
+ try {
1226
+ await refreshUsage({ force, ...usageReaders(), signal: usageAbort.signal });
1227
+ usageData = null;
1228
+ if (!closing) setMessage(`Usage updated ${clockTime(Date.now())}`);
1229
+ } finally { usageBusy = false; usageData = null; draw(); }
1230
+ }
1231
+ // Enter on a vendor's rows opens its details; r checks again now.
1232
+ async function usageAction(action) {
1233
+ if (action === 'refresh') return refreshUsageTab(true);
1234
+ const vendor = usageRows[usageList.selected ?? 0]?.vendor, row = usageData?.find(item => item.vendor === vendor);
1235
+ if (!row) return;
1236
+ await prompt(`${USAGE_NAMES[vendor]} usage`, usageDetailLines(row, new Date()).join('\n'));
1237
+ }
1238
+ function focusInput() { if (!closing && !modal) { ask.focus(); ask.readInput(); } }
1239
+ // Moves to the view's main pane, or to its buttons when it has none.
1240
+ function focusContent(index = 0) {
1241
+ if (modal) return;
1242
+ if (ask._reading) ask.cancel();
1243
+ const panes = viewPanes(), remembered = view === 'chats' && panes.includes(lastPane.chats) ? lastPane.chats : view === 'kanban' && panes.includes(lastPane.kanban) ? lastPane.kanban : null;
1244
+ const next = remembered ?? panes[Math.min(index, panes.length - 1)] ?? subButtons()[0] ?? activeTab();
1245
+ if (next === board) focusBoard(); else next.focus();
1246
+ draw();
1247
+ }
1248
+ function syncPalette() {
1249
+ const value = ask.getValue(), bang = value.trimStart().startsWith('!');
1250
+ // The label changes when the text becomes, or stops being, a shell command.
1251
+ if (bang !== shellTyped) { shellTyped = bang; if (!drawing) scheduleDraw(); }
1252
+ // So does a saved bot's @name at the start.
1253
+ const bot = value.trimStart().startsWith('@') && (sessionBox() || view === 'chat') ? botMessage(value, savedBots())?.bot.name ?? null : null;
1254
+ if (bot !== botTyped) { botTyped = bot; if (!drawing) scheduleDraw(); }
1255
+ // And a teammate's, when no bot has that name.
1256
+ const person = !bot && value.trimStart().startsWith('@') && (sessionBox() || view === 'chat') ? teammateNamed(value.trim().split(/\s+/)[0])?.name ?? null : null;
1257
+ if (person !== personTyped) { personTyped = person; if (!drawing) scheduleDraw(); }
1258
+ // And @model, which writes to the session's model.
1259
+ const own = TO_MODEL.test(value.trimStart());
1260
+ if (own !== modelTyped) { modelTyped = own; if (!drawing) scheduleDraw(); }
1261
+ if (value !== paletteDismissed) paletteDismissed = null;
1262
+ // Rows: a command, or the next word of one (/model default, then a model).
1263
+ // more: choosing it fills it in and keeps going instead of running it.
1264
+ const values = screen.focused === ask && !modal && value !== paletteDismissed ? [
1265
+ ...commandMatches(value).map(command => ({ value: `/${command.name}`, label: `/${command.name}`, description: command.description, more: needsArgument(command), usage: command.usage })),
1266
+ ...argumentMatches(value, paletteContext).map(row => ({ ...row, label: row.word })),
1267
+ ...(view === 'chats' ? mentionMatches(value, paletteContext) : sessionBox() || view === 'chat' ? sessionMentions(value, paletteContext) : []).map(row => ({ ...row, label: row.word })),
1268
+ ] : [];
1269
+ const changed = values.map(row => row.value).join() !== paletteValues.map(row => row.value).join();
1270
+ paletteValues = values; palette.hidden = values.length === 0;
1271
+ const lastText = paletteText; paletteText = value;
1272
+ if (values.length) {
1273
+ const g = geometry(screen.width, screen.height);
1274
+ palette.height = Math.min(values.length + 2, 10, g.askTop - g.top); palette.top = g.askTop - palette.height;
1275
+ Object.assign(palette, { left: 1, width: g.width - 2 });
1276
+ // Typing a whole command name selects it, so /peer runs /peer, not /peers.
1277
+ const exact = value !== lastText ? values.findIndex(row => row.value === value.trim().toLowerCase()) : -1;
1278
+ const selected = exact >= 0 ? exact : changed ? 0 : palette.selected ?? 0;
1279
+ palette.setItems(values.map(row => clip(`${row.label.padEnd(14)} ${row.description}`, palette.width - 4)));
1280
+ palette.items.forEach((item, index) => {
1281
+ item.removeAllListeners('click');
1282
+ item.on('click', () => catchAction(() => { if (values[index].more) choosePalette(values[index]); else return submit(values[index].value); }));
1283
+ });
1284
+ palette.select(Math.min(selected, values.length - 1)); palette.setFront();
1285
+ }
1286
+ }
1287
+ // Fills in a row that needs more words and shows what can come next.
1288
+ function choosePalette(row) {
1289
+ focusInput(); ask.setValue(`${row.value} `); syncPalette();
1290
+ if (row.usage) setMessage(`Usage: ${row.usage}`);
1291
+ draw();
1292
+ }
1293
+ // What the command list suggests after a command name. Vendor checks start programs, so one
1294
+ // list serves the session: /model add reads the copy. A vendor's one-time check that ran
1295
+ // since then changed what the list says, so it is read again.
1296
+ const vendorList = vendorReading();
1297
+ const cachedVendors = () => { if (!controller) return []; return vendorList.kept() ?? vendorList.read(() => controller.vendors()); };
1298
+ // The open session, and the bots in it; in the new session form, the ones it will start with.
1299
+ const openTask = () => controller && view === 'chat' && selectedId && !selectedRequest ? controller.snapshot().tasks.find(row => row.id === selectedId) ?? null : null;
1300
+ const chatBotsNow = () => { if (view !== 'chat') return []; if (composing()) return composeBots; const task = openTask(); return task ? botsOf(task) : []; };
1301
+ // The model in view: the open session's, else the one a new session starts with.
1302
+ const modelNow = () => { if (!controller) return null; const task = openTask(); return (task ? sessionModel(controller.repo, task) : composing() ? composeModel : null) ?? controller.defaultModel(); };
1303
+ const paletteContext = {
1304
+ // The open session's model, as @model reaches it; null where no session is open.
1305
+ model: () => { const task = view === 'chat' && controller && selectedId && !selectedRequest ? (snapshot?.tasks ?? []).find(row => row.id === selectedId) : null; return task ? modelOf(task) : null; },
1306
+ // The models a session can be given, with a mark on the one in use.
1307
+ models: () => { const choices = modelChoices(), now = modelNow(), current = now ? choiceName(now, choices) : null; return choices.map(row => ({ name: row.name, vendor: row.vendor, note: row.note, current: row.name === current })); },
1308
+ servers: () => controller ? controller.servers() : { all: [], on: [] },
1309
+ bots: () => { const here = chatBotsNow(); return savedBots().map(bot => ({ name: bot.name, about: bot.about, here: here.includes(bot.name), scheduled: Boolean(bot.schedule), color: botColor(bot) })); },
1310
+ teammates: () => {
1311
+ const status = peerRead('peerStatus'), members = new Map();
1312
+ for (const member of (status?.teams ?? []).flatMap(row => row?.members ?? [])) if (member?.key && member.key !== status.key && /^[^\s@/\\]{1,64}$/.test(member.name ?? '')) members.set(member.key, { name: member.name, online: Boolean(member.online), path: remote(member.path, 10) });
1313
+ return [...members.values()];
1314
+ },
1315
+ // The teammates /invite can add to the open channel, by the name the screen shows them under.
1316
+ invitees: () => { const room = openChannel(); return room ? { channel: room.name, people: outsiders(room).map(member => ({ name: peerName(member.name, member.key).slice(1), online: Boolean(member.online) })) } : null; },
1317
+ };
1318
+ // The teammate a leading @name means: the exact name first, then the same letters in any case.
1319
+ const teammateNamed = word => {
1320
+ if (!/^@[^\s@]+$/.test(word ?? '')) return null;
1321
+ const name = word.slice(1), rows = paletteContext.teammates();
1322
+ return rows.find(row => row.name === name) ?? rows.find(row => row.name.toLowerCase() === name.toLowerCase()) ?? null;
1323
+ };
1324
+ function showView(next) {
1325
+ if (!['tasks', 'projects', 'kanban', 'chats', 'bots', 'usage', 'chat'].includes(next)) throw new Error('Choose Sessions, Projects, Kanban, Chats, Bots, or Usage.');
1326
+ if (next === 'chat' && !controller) throw new Error(NO_PROJECT);
1327
+ rememberDraft(); view = next; message = ''; ask.clearValue();
1328
+ if (next !== 'chat') selectedRequest = null;
1329
+ // Sessions shows its cursor's row again. A row of a project that is no longer the active one gives way to + New session.
1330
+ if (next === 'tasks') { returnTo = 'tasks'; openKey = null; if (controller && barKey !== NEW && !barKey.startsWith(`${controller.repo.root}\0`)) barKey = NEW; }
1331
+ if (next === 'usage') usageData = null;
1332
+ restoreDraft(); resize();
1333
+ if (next === 'usage') catchAction(() => refreshUsageTab());
1334
+ }
1335
+ // Waits for the daemon's answer, so a refusal (an unknown teammate, a secret in a diff)
1336
+ // reaches the status line instead of a success message.
1337
+ // My bots, for the Chats bar. A bots file that cannot be read shows none here; the Bots tab says why.
1338
+ const myBots = () => { try { return listBots(); } catch { return []; } };
1339
+ // Where a teammate's bot runs: their machine's name, or their own name when they sent none.
1340
+ const botHome = bot => remote(bot.box, 32) || peerName(bot.owner, bot.peer);
1341
+ const peerCommand = async command => {
1342
+ if (!facade?.sendPeerCommand) throw new Error(PEER_MISSING);
1343
+ if (peerRead('peerStatus')?.running !== true) throw new Error(PEER_STOPPED);
1344
+ // A picture the message names goes with it (lib/peer/pictures.mjs); over a limit this throws its one line.
1345
+ if (typeof facade.withPictures === 'function') command = facade.withPictures(command);
1346
+ const { id } = facade.sendPeerCommand(command);
1347
+ return facade.awaitCommand ? facade.awaitCommand(id, 10000) : null;
1348
+ };
1349
+ // The daemon's answer to a send: a path outside the repo was cut for an untrusted teammate,
1350
+ // which the owner learns about here, or the usual line.
1351
+ const withCount = (result, line) => Number.isInteger(result?.pictures) && result.pictures > 0 ? line.replace('. ', ` with ${result.pictures === 1 ? '1 picture' : `${result.pictures} pictures`}. `) : line;
1352
+ const sentLine = (result, who, line) => setMessage(result?.pathsCut === true ? `Paths outside the repo were replaced with [path]. Trust ${who} to keep them.` : withCount(result, line));
1353
+ // The vendor of the model in view, for the picker's relay line.
1354
+ const leadVendor = () => { try { return modelNow()?.vendor ?? null; } catch { return null; } };
1355
+ // A vendor without a live relay here reports blocked actions instead.
1356
+ function relayLine(vendor) {
1357
+ if (!VENDORS.includes(vendor)) return [];
1358
+ const caps = CAPS[vendor], name = { claude: 'Claude', codex: 'Codex', agy: 'Antigravity', grok: 'Grok' }[vendor] ?? VENDOR_NAMES[vendor];
1359
+ let live = caps.relay === 'mcp';
1360
+ if (caps.relay === 'acp') { try { live = conformanceState(vendor)?.relay === true; } catch { live = false; } }
1361
+ return live ? [] : [`${name} can't ask live. Blocked actions are reported.`];
1362
+ }
1363
+ // Asked once for this run of the app: the answer starts a program, and the picker is drawn often.
1364
+ let shellNote = null;
1365
+ const shellLine = () => (shellNote ??= shellSandboxLine());
1366
+ // The Permissions picker: 1 ask, 2 auto, 3 full with its confirm. null when cancelled.
1367
+ async function pickMode({ schedule = false, vendor = leadVendor() } = {}) {
1368
+ const choices = ['ask', 'auto', ...(schedule ? [] : ['full'])];
1369
+ const body = [...choices.map((mode, index) => PICK(String(index + 1), LABELS[mode], MODE_TEXT[mode])), '', ...(schedule ? [SCHEDULE_CAP] : []), ...relayLine(vendor), ...shellLine(), '/permissions folders add <path> lets the model read one more folder.'].join('\n');
1370
+ const answer = (await prompt('Permissions', body, '', { keys: choices.map((_, index) => String(index + 1)) }))?.trim().toLowerCase();
1371
+ if (!answer) return null;
1372
+ const mode = MODES.includes(answer) ? answer : choices[Number(answer) - 1];
1373
+ if (!mode) throw new Error(`Choose 1 to ${choices.length}.`);
1374
+ checkMode(mode, { allowFull: !schedule });
1375
+ if (mode === 'full' && !await confirmFull()) return null;
1376
+ return mode;
1377
+ }
1378
+ const confirmFull = () => yes('Full access', 'Full access: the model changes any file and uses the internet without asking.\nPush, tag, merge and deploy are only asked of it, not blocked.', 'Use it? y/N');
1379
+ // The Runs in picker: a new worktree, a named branch, or your checkout (Full access only).
1380
+ async function pickPlace({ mode }) {
1381
+ const body = [PICK('1', PLACE_LABEL.worktree, PLACE_TEXT.worktree), PICK('2', PLACE_LABEL.branch, PLACE_TEXT.branch), PICK('3', PLACE_LABEL.here, PLACE_TEXT.here)].join('\n');
1382
+ const answer = (await prompt('Runs in', body, '', { keys: ['1', '2', '3'] }))?.trim().toLowerCase();
1383
+ if (!answer) return null;
1384
+ const kind = { 1: 'worktree', 2: 'branch', 3: 'here', worktree: 'worktree', branch: 'branch', here: 'here' }[answer];
1385
+ if (!kind) throw new Error('Choose 1 to 3.');
1386
+ if (kind !== 'branch') return checkPlace(kind, { mode });
1387
+ const branch = (await prompt('Branch', 'Branch name. Commits land on it; a worktree checks it out.', ''))?.trim();
1388
+ return branch ? checkPlace({ kind, branch }, { mode }) : null;
1389
+ }
1390
+ // Adds one folder the model may read this session; "none" clears the list. null when cancelled.
1391
+ async function pickFolders(current) {
1392
+ const body = [...(current.length ? current.map(shortHome) : ['none']), '', 'A folder in your home the model may read this session.', 'none clears the list. Esc keeps it.'].join('\n');
1393
+ const answer = (await prompt('Folders', body, ''))?.trim();
1394
+ if (!answer) return null;
1395
+ return answer === 'none' ? [] : checkFolders([...current, expandHome(answer)]);
1396
+ }
1397
+ // The picker a teammate's message gets when the owner works on it: the rows of
1398
+ // the compose form in one dialog, 1 to 3 change a row, Enter starts it. offered: folders a
1399
+ // trusted sender named. null when cancelled.
1400
+ async function workPicker({ offered = [] } = {}) {
1401
+ let mode = defaultMode(), place = { kind: 'worktree' }, folders = offered, fromMessage = offered.length > 0;
1402
+ for (;;) {
1403
+ const rows = [`Permissions ${LABELS[mode].padEnd(22)}1 to change`, ` ${MODE_TEXT[mode]}`,
1404
+ `Runs in ${placeText(place).padEnd(22)}2 to change`, ` ${placeHint(place)}`,
1405
+ `Folders ${folders.length ? `${folders.map(shortHome).join(' ')} ${fromMessage ? 'from their message · ' : ''}` : 'none'.padEnd(22)}3 to change`, ' Other folders it may read.'];
1406
+ const answer = (await prompt('Accept', [...rows, '', 'Enter starts it as your session. Esc leaves it waiting.'].join('\n'), '', { keys: ['1', '2', '3'] }))?.trim();
1407
+ if (answer === null || answer === undefined) return null;
1408
+ if (answer === '') { checkPlace(place, { mode }); return { permissions: mode, place, folders }; }
1409
+ if (answer === '1') { mode = await pickMode() ?? mode; if (place.kind === 'here' && mode !== 'full') place = { kind: 'worktree' }; }
1410
+ else if (answer === '2') place = await pickPlace({ mode }) ?? place;
1411
+ else if (answer === '3') { const next = await pickFolders(folders); if (next) { folders = next; fromMessage = false; } }
1412
+ else throw new Error('Press 1, 2 or 3 to change a row, Enter to start.');
1413
+ }
1414
+ }
1415
+ // Owner decisions on a teammate's message, from Tasks or Threads:
1416
+ // - an incoming message: accept (the owner's own session, in a mode they pick now; the
1417
+ // agent answers a question or does the task) through the daemon, or decline. An older
1418
+ // saved request row is removed with its decline.
1419
+ // - a held reply or an agent draft lives only with the daemon: y sends, n discards.
1420
+ async function decidePeer({ request, item, root }, decision) {
1421
+ const who = request ? peerName(request.origin?.name, request.origin?.peer) : threadWho(item);
1422
+ if (request || item?.kind === 'incoming') {
1423
+ const at = request ? root : item.root, requestId = request?.id ?? item.requestId, id = item?.id ?? request?.origin?.a2aTaskId;
1424
+ if (!id) throw new Error('That message is no longer waiting.');
1425
+ if (decision === 'answer') {
1426
+ const picked = await workPicker({ offered: item?.trusted === true ? foldersNamed(item.text) : [] });
1427
+ if (!picked) return;
1428
+ await peerCommand({ type: 'work', id, permissions: picked.permissions, place: picked.place, folders: picked.folders });
1429
+ setMessage(`Accepted ${who}. It runs as your session; the reply goes back to this thread.`); return;
1430
+ }
1431
+ if (typeof at === 'string' && at && typeof requestId === 'string') removeRequest(at, requestId);
1432
+ await peerCommand({ type: 'discard', id });
1433
+ setMessage(`Declined ${who}.`); return;
1434
+ }
1435
+ const accept = decision === 'answer';
1436
+ // n on a thread that waits for nothing dismisses it; an open ask is also cancelled with the teammate.
1437
+ if (!accept && ['answer', 'asked', 'expired', 'rejected'].includes(item?.kind)) { await peerCommand({ type: 'discard', id: item.id }); setMessage(item.kind === 'asked' ? `Cancelled the ask to ${who}.` : 'Dismissed.'); return; }
1438
+ if (!['held', 'draft'].includes(item?.kind)) throw new Error('y works on messages, replies, and drafts that wait for you.');
1439
+ const result = await peerCommand({ type: accept ? 'approve' : 'discard', id: item.id });
1440
+ if (accept) sentLine(result, who, item.kind === 'held' ? `Sending the reply to ${who}.` : `Sending the ask to ${who}.`); else setMessage('Discarded.');
1441
+ }
1442
+ // Trust a teammate or remove it, after a confirm.
1443
+ async function trustPeer(value) {
1444
+ if (value?.type !== 'member' || value.self) throw new Error('Choose a teammate first.');
1445
+ const who = peerName(value.name, value.key), trusted = value.trusted ?? peerTrusted(value.key);
1446
+ if (trusted) {
1447
+ if (!await yes(`Trust ${who}`, `Remove trust for ${who}? Paths go out as [path] again.`, 'y/N')) return;
1448
+ await peerCommand({ type: 'untrust', key: value.key }); setMessage(`Removed trust for ${who}. Paths go out as [path].`); return;
1449
+ }
1450
+ if (!await yes(`Trust ${who}`, `Trust ${who}? Paths on this machine reach theirs as written.\nSecrets are still held back.`, 'y/N')) return;
1451
+ await peerCommand({ type: 'trust', key: value.key }); setMessage(`Trusted ${who}. Paths go out as they are. Their text shows as sent.`);
1452
+ }
1453
+ // Returns the letter pressed when the dialog names keys (y, w, n, c, f, m or t), else null.
1454
+ async function peerDetails(value) {
1455
+ const text = value => sanitize(value, 160000);
1456
+ // Text other than a named letter does nothing, and says so. preselect: the letter Enter picks.
1457
+ const keyed = async (title, body, keys = [], { preselect = '', trusted = false } = {}) => {
1458
+ const typed = String(await prompt(title, body, preselect, { keys, preselect, trusted }) ?? '').trim().toLowerCase();
1459
+ if (keys.includes(typed)) return typed;
1460
+ if (typed && keys.length) throw new Error(`Nothing was done. Press ${keys.join(' or ')}.`);
1461
+ return null;
1462
+ };
1463
+ if (value.type === 'member') {
1464
+ const trusted = value.trusted ?? peerTrusted(value.key);
1465
+ return keyed(`Teammate ${peerName(value.name, value.key)}`, [`Key: ${value.key}`, `Fingerprint: ${text(value.member.fingerprint)}`, `Team: ${text(value.team?.name)}`, `Role: ${text(value.member.role)}`, `State: ${value.self ? 'you' : value.member.online ? `online, ${text(value.member.path ?? 'direct')}` : 'offline'}`, `Last seen: ${relativeTime(value.member.lastSeen)}`, ...(value.self ? [] : [trusted ? 'Trust: trusted · paths go out as they are.' : 'Trust: not trusted · paths go out as [path].', '', trusted ? 't removes trust' : 't trusts them'])].join('\n'), value.self ? [] : ['t']);
1466
+ }
1467
+ if (value.type === 'agent') return keyed(`Sessions of ${peerName(value.name, value.peer)}`, value.row ? [`${text(value.row.handle)} ${text(value.row.label)}`, text(value.row.now), '', 'Overlap with your work:', ...(value.row.overlap ?? []).map(text), '', 'Changed files:', ...(Array.isArray(value.row.files) ? value.row.files.slice(0, 200).map(text) : [])].join('\n') : 'No live sessions.');
1468
+ // Any message is accepted the same way: it runs as the owner's session.
1469
+ const [hint, keys, preselect] = value.kind === 'answer' ? ['c continues in the chat with this answer.', ['c'], ''] : value.kind === 'incoming' ? ['y accepts in a mode you pick. n declines.', ['y', 'n'], 'y'] : ['held', 'draft'].includes(value.kind) ? ['y sends. n discards.', ['y', 'n'], ''] : ['', [], ''];
1470
+ // The thread reads like a chat: who said what, oldest first, from the thread's log.
1471
+ let messages = []; try { messages = value.threadId && typeof facade?.threadMessages === 'function' ? facade.threadMessages(value.threadId) : []; } catch { messages = []; }
1472
+ const TAG = { ask: 'question', review: 'review', handoff: 'task', answer: 'answer', held: 'answer', blocked: 'answer' };
1473
+ const head = m => [`${TAG[m.kind] === 'answer' ? '↳ ' : ''}${m.from === 'peer' ? peerName(m.name, m.peer) : m.held ? 'your model' : 'you'}`, TAG[m.kind] ?? text(m.kind), ...(m.held ? [m.blocked ? 'held: possible secret' : 'not sent yet'] : []), clockTime(m.at)].join(' · ');
1474
+ const talk = messages.length ? messages.flatMap(m => m.from === 'note' ? [`(${text(m.text)})`, ''] : [head(m), text(m.text), '']) : [text(value.text), ''];
1475
+ return keyed(`Thread with ${threadWho(value)}`, [`Project: ${text(value.project) || 'not named'}`, '', ...talk, hint].join('\n').trimEnd(), keys, { preselect, trusted: value.trusted === true });
1476
+ }
1477
+ // Panes: 0 the bar (channels, then teammates with their live agents), 1 Threads.
1478
+ async function teamAction(index, action) {
1479
+ const value = teamRows[index][teamLists[index].selected ?? 0]?.value;
1480
+ // The bar: Enter on a channel or a person moves to the message box; the add rows start their flow.
1481
+ if (index === 0) {
1482
+ if (action === 'decline') action = 'new';
1483
+ if (action === 'open' && value?.type === 'add') action = value.what === 'channel' ? 'new' : 'invite';
1484
+ if (action === 'open' && value?.target) { focusInput(); draw(); return; }
1485
+ if (action === 'new') return newChannel();
1486
+ if (action === 'invite') return peerSlash({ name: 'invite', args: [] });
1487
+ }
1488
+ // m and x act on the channel under the cursor in the bar, and on the open channel from its threads.
1489
+ if (['members', 'leave'].includes(action)) {
1490
+ const room = index === 0 ? value : chatOpen;
1491
+ if (room?.type === 'room' && room.channel) return action === 'members' ? channelMembers(room) : leaveChannel(room);
1492
+ if (room?.type === 'room' && action === 'members') { setMessage('Everyone on the team is in #general. + Add teammate invites someone.'); return; }
1493
+ if (index === 0) throw new Error('Choose a channel you made or were added to. #general is everyone.');
1494
+ return;
1495
+ }
1496
+ if (action === 'open') {
1497
+ if (index === 0 && value?.type !== 'agent') return;
1498
+ else {
1499
+ const key = value ? await peerDetails(value) : null, next = { y: 'accept', n: 'decline', c: 'continue', t: 'trust' }[key];
1500
+ return next ? teamAction(index, next) : undefined;
1501
+ }
1502
+ }
1503
+ if (['accept', 'decline'].includes(action)) { if (index !== 1 || !value) throw new Error('Choose a thread that waits for you.'); return decidePeer({ item: value }, action === 'accept' ? 'answer' : 'decline'); }
1504
+ // t shows the teammate with their Trust line; t there confirms the change.
1505
+ if (action === 'trust') {
1506
+ if (index !== 0 || value?.type !== 'member' || value.self) throw new Error('Choose a teammate first.');
1507
+ return await peerDetails(value) === 't' ? trustPeer(value) : undefined;
1508
+ }
1509
+ if (action === 'ask') {
1510
+ if (index !== 0 || !['member', 'agent'].includes(value?.type)) throw new Error('Choose a teammate first.');
1511
+ focusInput(); ask.setValue(`@${peerName(value.name, value.peer ?? value.key).slice(1)} `); draw(); return;
1512
+ }
1513
+ if (action === 'continue') { if (index !== 1 || value?.kind !== 'answer') throw new Error('c continues with an answer. Choose one in Threads.'); await peerCommand({ type: 'continue', id: value.id }); setMessage('Continuing in the chat with the answer.'); return; }
1514
+ }
1515
+ // Channels. A channel is made on a team from the people already on it, and reaches them
1516
+ // as a signed change to the team's roster.
1517
+ const myKey = () => peerRead('peerStatus')?.key ?? null;
1518
+ async function pickMembers(title, people, chosen, done) {
1519
+ const picked = new Set(chosen);
1520
+ for (let selected = 0; ;) {
1521
+ const rows = [...people.map(person => ink(`${picked.has(person.key) ? '[x]' : '[ ]'} ${peerName(person.name, person.key)}`, 'text', colour)), ink(done, 'text', colour, true)];
1522
+ const choice = await listDialog(title, rows, { selected, hint: 'enter tick or finish · esc cancel' });
1523
+ if (!choice) return null;
1524
+ if (choice.index === people.length) return [...picked];
1525
+ const { key } = people[choice.index]; if (picked.has(key)) picked.delete(key); else picked.add(key);
1526
+ selected = choice.index;
1527
+ }
1528
+ }
1529
+ // Which of my bots teammates may write to. With none ticked, their messages come to me as before.
1530
+ async function shareBots() {
1531
+ const bots = myBots().filter(bot => canShare(bot.vendor)); if (!bots.length) return '';
1532
+ const picked = await pickMembers('Bots your teammates can use', bots.map(bot => ({ key: bot.name, name: bot.name })), bots.filter(bot => bot.shared).map(bot => bot.name), 'Done');
1533
+ if (picked) for (const bot of bots) if (bot.shared !== picked.includes(bot.name)) saveBot({ ...bot, shared: picked.includes(bot.name) });
1534
+ const shared = myBots().filter(bot => bot.shared && canShare(bot.vendor)).map(bot => `@${bot.name}`);
1535
+ return shared.length ? `Teammates can write to ${shared.join(', ')}.` : 'No bot is shared, so a teammate’s message comes to you.';
1536
+ }
1537
+ async function newChannel() {
1538
+ const teams = (peerRead('peerStatus')?.teams ?? []).filter(team => (team.members ?? []).some(member => member.key !== myKey()));
1539
+ if (!teams.length) throw new Error('Add a teammate first: /invite.');
1540
+ const picked = teams.length === 1 ? { index: 0 } : await listDialog('Team for the channel', teams.map(team => ink(remote(team.name, 40) || 'Team', 'text', colour)));
1541
+ if (!picked) return;
1542
+ const team = teams[picked.index], people = team.members.filter(member => member.key !== myKey());
1543
+ const name = (await prompt('New channel', 'Name it with lowercase letters, digits, - or _. Up to 30 characters.\nNext you choose who is in it.'))?.trim().replace(/^#/, '').toLowerCase();
1544
+ if (!name) return;
1545
+ if (!/^[a-z0-9][a-z0-9_-]{0,29}$/.test(name) || name === 'general') throw new Error('Name a channel with 1 to 30 lowercase letters, digits, - or _. general is taken.');
1546
+ const members = await pickMembers(`Who is in #${name}`, people, [], 'Create channel');
1547
+ if (!members) return;
1548
+ if (!members.length) throw new Error('Tick at least one teammate, then Create channel.');
1549
+ const result = await peerCommand({ type: 'channelSet', team: team.id, name, members: [myKey(), ...members] });
1550
+ if (result?.id) chatJump = roomTarget(team.id, result.id);
1551
+ setMessage(`Created #${name} with ${members.length} teammate${members.length === 1 ? '' : 's'}. They see it when they are online.`); draw();
1552
+ }
1553
+ // The channel the message box writes to. #general, a direct message and a bot's chat are not one.
1554
+ const openChannel = () => view === 'chats' && teamOn && chatOpen?.type === 'room' && chatOpen.channel ? chatOpen : null;
1555
+ // The teammates on a channel's team who are not in it.
1556
+ const outsiders = room => ((peerRead('peerStatus')?.teams ?? []).find(row => row.id === room.team)?.members ?? []).filter(member => member.key !== myKey() && !room.members.includes(member.key));
1557
+ // Who is in a channel besides me, saved as one signed change to the team's roster.
1558
+ const saveChannel = (room, others) => peerCommand({ type: 'channelSet', team: room.team, id: room.channel, name: room.name, members: [myKey(), ...others] });
1559
+ async function channelMembers(room) {
1560
+ const team = (peerRead('peerStatus')?.teams ?? []).find(row => row.id === room.team); if (!team) throw new Error('That team is gone.');
1561
+ const people = team.members.filter(member => member.key !== myKey());
1562
+ const members = await pickMembers(`Who is in #${room.name}`, people, room.members.filter(key => key !== myKey()), 'Save');
1563
+ if (!members) return;
1564
+ if (!members.length) throw new Error('Tick at least one teammate. To leave the channel, press x on it.');
1565
+ await saveChannel(room, members);
1566
+ setMessage(`#${room.name} now has ${members.length} teammate${members.length === 1 ? '' : 's'} and you.`); draw();
1567
+ }
1568
+ // /invite with a name: one teammate joins the open channel, the change m makes for them.
1569
+ // Where there is nobody to add, one line says why, and what plain /invite is for.
1570
+ async function inviteToChannel(word) {
1571
+ const typed = `@${word.replace(/^@+/, '')}`, fresh = '/invite alone makes a code for someone new.', shown = member => peerName(member.name, member.key);
1572
+ const status = peerRead('peerStatus'), teams = status?.teams ?? [];
1573
+ if (!teams.some(team => (team.members ?? []).some(member => member.key !== status.key))) throw new Error(`No teammates yet. ${fresh}`);
1574
+ const room = openChannel(), open = view === 'chats' && teamOn ? chatOpen : null;
1575
+ if (!room && open?.type === 'room') throw new Error(`Everyone is in #general already. ${fresh}`);
1576
+ if (!room && open?.type === 'member') throw new Error('A direct message is just the two of you. Open a channel to add a teammate.');
1577
+ if (!room && open?.type === 'bot') throw new Error('A bot’s chat has no members. Open a channel to add a teammate.');
1578
+ if (!room) throw new Error('Open a channel in Chats first: /invite @name adds a teammate to it.');
1579
+ // The exact name first, then the same letters in any case.
1580
+ const named = rows => { const same = rows.filter(member => shown(member) === typed); return same.length ? same : rows.filter(member => shown(member).toLowerCase() === typed.toLowerCase()); };
1581
+ const out = outsiders(room), found = named(out);
1582
+ if (found.length > 1) throw new Error(`Two teammates are called ${typed}. Press m on #${room.name} and tick the one you mean.`);
1583
+ if (!found.length) {
1584
+ const inside = named((teams.find(team => team.id === room.team)?.members ?? []).filter(member => member.key !== status.key && room.members.includes(member.key)));
1585
+ if (inside.length) throw new Error(`${shown(inside[0])} is already in #${room.name}.`);
1586
+ if (!out.length) throw new Error(`Everyone is in #${room.name} already. ${fresh}`);
1587
+ throw new Error(`No teammate ${typed} to add to #${room.name}. Not in it yet: ${out.slice(0, 5).map(shown).join(', ')}${out.length > 5 ? ` and ${out.length - 5} more` : ''}.`);
1588
+ }
1589
+ await saveChannel(room, [...room.members.filter(key => key !== myKey()), found[0].key]);
1590
+ setMessage(`Added ${shown(found[0])} to #${room.name}.`); draw();
1591
+ }
1592
+ async function leaveChannel(room) {
1593
+ const others = room.members.filter(key => key !== myKey());
1594
+ if (!await yes(`Leave #${room.name}`, others.length ? 'You stop getting its messages. The others stay in it.' : 'You are the last one in it, so the channel is removed.', 'y/N')) return;
1595
+ await peerCommand({ type: 'channelSet', team: room.team, id: room.channel, name: room.name, members: others });
1596
+ setMessage(`Left #${room.name}.`); draw();
1597
+ }
1598
+ const usageOf = name => COMMANDS.find(row => row.name === name).usage;
1599
+ const hasIdentity = () => { try { facade.identity(); return true; } catch (error) { if (/identity first/.test(error.message)) return false; throw error; } };
1600
+ // Peer slash commands. Each makes one facade call; the result shows in the status line.
1601
+ async function peerSlash({ name, args, text = '' }) {
1602
+ if (!facade) throw new Error(PEER_MISSING);
1603
+ if (name === 'peer') {
1604
+ if (args.length > 1 || args[0] && !['on', 'off'].includes(args[0])) throw new Error('Use /peer, /peer on or /peer off.');
1605
+ const turn = args[0] ?? (peerOn() ? 'off' : 'on');
1606
+ if (turn === 'on' && typeof facade.identity === 'function' && !hasIdentity()) {
1607
+ const name = (await prompt('Your name', 'Teammates see this name on your asks and answers. 1 to 40 characters.', os.userInfo().username))?.trim();
1608
+ if (!name) { setMessage('Peer mode stays off. /peer on asks for your name again.'); return; }
1609
+ facade.identity({ create: true, name });
1610
+ }
1611
+ await (turn === 'on' ? facade.startPeer() : facade.stopPeer()); setMessage(`Peer mode is ${turn}.`); return;
1612
+ }
1613
+ if (name === 'share') {
1614
+ if (!controller) throw new Error('Open a project to share it.');
1615
+ if (args.length > 1 || args[0] && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(args[0])) throw new Error('Use /share [handle] with 1 to 40 lowercase letters, digits or dashes.');
1616
+ controller.writable(); const result = await facade.share(controller.repo.root, { handle: args[0], status: true });
1617
+ setMessage(`Shared ${shortHome(result?.root ?? controller.repo.root)} with your team as ${remote(result?.handle, 40) || args[0] || path.basename(controller.repo.root)}.`); return;
1618
+ }
1619
+ if (!peerOn()) throw new Error(PEER_OFF);
1620
+ if (name === 'peers') {
1621
+ const status = peerRead('peerStatus'), members = new Map();
1622
+ for (const member of (status?.teams ?? []).flatMap(row => row?.members ?? [])) if (member?.key && member.key !== status.key) members.set(member.key, member);
1623
+ const online = [...members.values()].filter(member => member.online);
1624
+ setMessage(members.size ? `${online.length} of ${members.size} teammates online${online.length ? `: ${online.map(member => `${peerName(member.name, member.key)} (${remote(member.path, 10) || 'direct'})`).join(', ')}` : ''}` : 'No teammates yet. Use /invite to add one.'); return;
1625
+ }
1626
+ // Adding a teammate also picks the project they ask about: the open one.
1627
+ const teamShare = async teamId => {
1628
+ if (!controller) return 'Type /share in a project so teammates can ask about it.';
1629
+ try { controller.writable(); const row = await facade.share(controller.repo.root, { teams: [teamId] }); return `Teammates can ask about ${remote(row?.handle, 40) || path.basename(controller.repo.root)}, the folder ${shortHome(row?.root ?? controller.repo.root)}.`; }
1630
+ catch (error) { return `${path.basename(controller.repo.root)} is not shared: ${error.message}`; }
1631
+ };
1632
+ if (name === 'invite') {
1633
+ const teams = peerRead('peerStatus')?.teams ?? [];
1634
+ // A name adds a teammate to the open channel: @name always, a bare word unless a team is called that.
1635
+ if (teams.length && args.length === 1 && (args[0].startsWith('@') || !teams.some(row => row.id === args[0] || row.name === args[0]))) return inviteToChannel(args[0]);
1636
+ const chosen = args.length ? teams.find(row => row.id === args[0] || row.name === args.join(' ')) : teams[0];
1637
+ if (!chosen) throw new Error(teams.length ? 'No team by that name.' : 'Create a team first: elu team create NAME.');
1638
+ const result = await facade.createInvite(chosen.id, { hours: 24 }), code = typeof result === 'string' ? result : result?.code;
1639
+ if (typeof code !== 'string' || !code.startsWith('elu-team:')) throw new Error('The invite could not be made.');
1640
+ const shared = await teamShare(chosen.id), bots = await shareBots();
1641
+ setMessage(`Invite for ${remote(chosen.name, 40)} ready. It works once, within 24 h.`);
1642
+ await prompt('Team invite', `${code}\n\nOne use, expires in 24 h.\n${shared}\n${bots ? `${bots}\n` : ''}Your teammate types /join and this code in the project you can ask about.\nStay online until they join.`); return;
1643
+ }
1644
+ if (name === 'join') {
1645
+ if (args.length !== 1 || !args[0].startsWith('elu-team:')) throw new Error(`Use ${usageOf('join')}.`);
1646
+ if (peerRead('peerStatus')?.running !== true) throw new Error(PEER_STOPPED);
1647
+ setMessage('Joining the team…'); draw();
1648
+ const joined = await facade.joinTeam(args[0]);
1649
+ const shared = await teamShare(joined?.id), bots = await shareBots();
1650
+ setMessage(`Joined ${remote(joined?.name, 40) || 'the team'}. ${shared}${bots ? ` ${bots}` : ''}`); return;
1651
+ }
1652
+ const target = args[0] ?? '';
1653
+ // No @name: the question goes to every teammate, and the first to take it answers.
1654
+ if (target && !target.startsWith('@')) {
1655
+ if (text.length > 16000) throw new Error('Keep it under 16,000 characters.');
1656
+ const chat = controller && view === 'chat' ? { root: controller.repo.root, taskId: selectedId ?? null } : {};
1657
+ const result = await peerCommand({ type: 'teamAsk', project: null, text, ...chat });
1658
+ setMessage(withCount(result, 'Asked your team. The first teammate to take it answers.')); return;
1659
+ }
1660
+ if (!/^@[^\s@/\\]{1,64}$/.test(target)) throw new Error('Write @name and your question.');
1661
+ const to = target.slice(1);
1662
+ // A second word names a project only when it is one of that teammate's shared handles.
1663
+ let rest = text.slice(target.length).trim(), project = null;
1664
+ // Handles come from the teammate's hello (in peerStatus), and from their live rows.
1665
+ const plain = value => String(value ?? '').replace(/[\s@]+/g, '').toLowerCase(), members = (peerRead('peerStatus')?.teams ?? []).flatMap(row => row?.members ?? []).filter(member => member?.key === to || member?.name === to || plain(member?.name) === plain(to));
1666
+ const word = rest.split(/\s+/, 1)[0], handles = [...members.flatMap(member => Array.isArray(member.handles) ? member.handles : []), ...peerList('teamAgents').filter(row => row?.name === to).flatMap(row => (row.rows ?? []).map(value => value?.handle))];
1667
+ if (word && rest.length > word.length && handles.includes(word)) { project = word; rest = rest.slice(word.length).trim(); }
1668
+ if (!rest) throw new Error('Write your question after @name.');
1669
+ if (rest.length > 16000) throw new Error('Keep it under 16,000 characters.');
1670
+ const chat = controller && view === 'chat' ? { root: controller.repo.root, taskId: selectedId ?? null } : {};
1671
+ // A question is also how work is handed over: the teammate chooses to answer
1672
+ // it or to work on it.
1673
+ const result = await peerCommand({ type: 'ask', to, project, text: rest, ...chat });
1674
+ sentLine(result, `@${to}`, `Sent to @${to}. It runs after they accept it.`);
1675
+ }
1676
+ // One switch at a time: moving through the list asks for the next one while the last still closes.
1677
+ let switching = Promise.resolve();
1678
+ function switchProject(location) {
1679
+ const next = switching.then(async () => {
1680
+ if (controller?.repo.root === location.root) return;
1681
+ rememberDraft(); await controller?.close(); claim.release(); controller = null;
1682
+ activateProject(location);
1683
+ });
1684
+ switching = next.catch(() => {}); return next;
1685
+ }
1686
+ // A folder typed in the project chooser: opened, or created after a confirm when it is missing.
1687
+ async function openProject(value, create) {
1688
+ let location;
1689
+ if (create) { const plan = createFolderPlan(value, startDirectory); if (!await yes('Create project folder', `${plan.root}\n\n${plan.message}`)) return false; location = createFolder(plan); nameProject(location, path.basename(location.root)); }
1690
+ else location = projectFolder(value, startDirectory);
1691
+ await switchProject(location); return true;
1692
+ }
1693
+ // + New task: an empty chat in the open project, else the only one, else one you choose.
1694
+ async function newTaskForm(root = null) {
1695
+ const projects = snapshot?.projects ?? [];
1696
+ root ??= controller?.repo.root ?? (projects.length === 1 ? projects[0].root : null);
1697
+ if (!root && !await (projects.length ? chooseProject() : createHere())) return;
1698
+ if (root) await switchProject(projectFolder(root));
1699
+ if (!controller) return;
1700
+ if (view !== 'chat') returnTo = view === 'kanban' ? 'kanban' : 'tasks';
1701
+ showForm(); message = '';
1702
+ resize(); focusInput();
1703
+ }
1704
+ async function nameHere(location) {
1705
+ const name = await prompt('New project', `${location.root}\n\nName this project. Its settings are saved in .elu-code/ in this folder.\nThe model finds your code anywhere in your home folder.\nEsc skips${location.common ? ' and opens the repository unnamed' : ''}.`, path.basename(location.root));
1706
+ if (!name?.trim()) { if (location.common && !controller) { activateProject(location); resize(); } return false; }
1707
+ const saved = nameProject(location, name);
1708
+ await switchProject(location); resize(); focusInput();
1709
+ setMessage(`Created project ${saved}. Type below to start a session.`); return true;
1710
+ }
1711
+ // No project open: name the folder ELU Code started in; the disk root picks another.
1712
+ async function createHere() {
1713
+ let location = null; try { location = projectFolder(startDirectory); } catch { /* The disk root is never a project. */ }
1714
+ if (!location) return chooseProject();
1715
+ const saved = savedProjectName(location.root);
1716
+ if (saved) { await switchProject(location); return true; }
1717
+ return nameHere(location);
1718
+ }
1719
+ // Bots keys act on the selected bot: Enter opens it, n makes one, x removes one.
1720
+ const botDialog = botDialogs({ prompt, listDialog, editSetting, yes, setMessage, colour, root: () => controller ? controller.repo.project ?? controller.repo.root : null, named: botModelName });
1721
+ async function botsAction(action) {
1722
+ botCache.at = 0;
1723
+ if (action === 'new') { const made = await botDialog.create(); if (made) botJump = made.name; return; }
1724
+ const bot = botRows[botsList.selected ?? 0];
1725
+ if (!bot) throw new Error('No bots yet. n makes one.');
1726
+ if (action === 'remove') { await botDialog.remove(bot.name); return; }
1727
+ return botDialog.open(bot.name);
1728
+ }
1729
+ // /bots add NAME and /bots remove NAME change the bots of the open session, or of the one about to start.
1730
+ // again: adding a bot that is already there says so without an error, as @name alone does.
1731
+ function chatBot(action, name, { again = false } = {}) {
1732
+ const task = openTask();
1733
+ if (!task && !composing()) throw new Error('Open a session first.');
1734
+ const now = task ? botsOf(task) : composeBots, has = now.includes(name);
1735
+ if (action === 'add') needBot(name);
1736
+ // A bot that has a schedule works in the session by itself, and the owner is told when.
1737
+ // One that works with no schedule waits to be written to.
1738
+ // before: its schedule here as it was ahead of the tick that this add started.
1739
+ const joined = (list, before = null) => {
1740
+ const runs = botScheduleIn(controller.repo, task ? { ...task, bots: list } : null, name);
1741
+ if (!runs && botShadows(getBot(name))) return `@${name} is in this session. It reviews the changes, and speaks after each turn that changes no files.`;
1742
+ if (!runs) return botWorks(needBot(name)) ? `@${name} is in this session. It works when you write to @${name}.` : `@${name} is in this session. It reviews the changes.`;
1743
+ if (!runs.on && runs.note) return `@${name} is in this session. ${runs.note}`;
1744
+ if (runs.off) return `@${name} is in this session. Its schedule is off here; /bots schedule on ${name} starts it.`;
1745
+ const every = runs.label ?? 'on its schedule', stop = `/bots schedule off ${name} stops the schedule here.`;
1746
+ // The form has no session yet: Enter in the empty box starts it with this bot's first turn.
1747
+ if (!task) return composeWhen || !runs.label ? `@${name} is in this session. It works here ${every} once the session starts, and when you write to @${name}.` : `@${name} is in this session. Press Enter to start it, or send your first message.`;
1748
+ if (before && runs.lastRunAt && runs.lastRunAt !== before.lastRunAt) return `@${name} is in this session and starts now. It then works here ${every}. ${stop}`;
1749
+ if (runs.starting) return `@${name} is in this session. It starts when the session is free, then works here ${every}. ${stop}`;
1750
+ return `@${name} is in this session. It works here ${every}${runs.nextRunAt ? `, next ${dateText(new Date(runs.nextRunAt))}` : ''}, and when you write to @${name}. ${stop}`;
1751
+ };
1752
+ if (has && again && action === 'add') { setMessage(joined(now)); return; }
1753
+ if (has === (action === 'add')) throw new Error(has ? `@${name} is already in this session.` : `@${name} is not in this session.`);
1754
+ // A bot that was deleted since drops out here: the store takes saved names only.
1755
+ const saved = listBots().map(bot => bot.name), rest = now.filter(row => row !== name && saved.includes(row)), next = has ? rest : [...rest, name];
1756
+ if (task) controller.setBots(task.id, next); else composeBots = chatBots(next);
1757
+ // A bot that has a schedule starts here now: the tick queues its first run.
1758
+ const before = !has && task ? botScheduleIn(controller.repo, { ...task, bots: next }, name) : null;
1759
+ if (before) tickBots();
1760
+ setMessage(has ? `@${name} left this session.` : joined(next, before));
1761
+ }
1762
+ // The Bots row: every saved bot, with a tick on the ones in the session. Enter adds or
1763
+ // removes the selected one; Esc closes.
1764
+ async function pickBots() {
1765
+ for (let selected = 0; ;) {
1766
+ botCache.at = 0; const bots = savedBots(), here = chatBotsNow();
1767
+ if (!bots.length) throw new Error('No bots yet. The Bots tab makes one.');
1768
+ const picked = await listDialog('Bots in this session', bots.map(bot => ink(here.includes(bot.name) ? '[x] ' : '[ ] ', here.includes(bot.name) ? 'orange' : 'muted', colour, true) + ink(`@${bot.name}`, 'text', colour, true) + ink(bot.about ? ` ${fitWords(bot.about, 40)}` : '', 'muted', colour)), { hint: 'enter add or remove · esc close', selected });
1769
+ if (!picked) return;
1770
+ selected = picked.index; chatBot(here.includes(bots[selected].name) ? 'remove' : 'add', bots[selected].name);
1771
+ }
1772
+ }
1773
+ // Projects keys act on the selected project: Enter opens its settings, o makes it the
1774
+ // active project, n creates one, x archives one, or leaves one that was joined.
1775
+ async function projectsAction(action) {
1776
+ if (action === 'new') return newProjectFlow();
1777
+ const project = projectRows[projectsList.selected ?? 0];
1778
+ if (!project) throw new Error('No projects yet. n creates one.');
1779
+ if (action === 'archive') return archiveRow(project);
1780
+ if (project.unavailable) throw new Error(project.unavailable);
1781
+ if (action === 'open') return makeActive(project);
1782
+ return projectSettings(project);
1783
+ }
1784
+ // The active project is the one in the header. The list stays in front, with its mark moved.
1785
+ async function makeActive(project) {
1786
+ if (controller?.repo.root === project.root) { setMessage(`${project.name} is already the active project.`); return; }
1787
+ await switchProject(projectFolder(project.root)); resize();
1788
+ setMessage(`${project.name} is now the active project.`); projectsList.focus(); draw();
1789
+ }
1790
+ // x, and the last row of a project's page. A project of the owner's own is archived: it
1791
+ // leaves the list, and a folder that was open is closed first. One joined through a
1792
+ // teammate's team is left: its share ends first, and when that fails the error shows and
1793
+ // the project stays on the list.
1794
+ async function archiveRow(project) {
1795
+ const teams = joinedTeamsOf(project.root), names = teams.map(team => remote(team.name, 40) || 'a team').join(', ');
1796
+ const sure = teams.length
1797
+ ? await yes(`Leave ${project.name}`, `It is no longer shared with ${names}: this machine stops answering teammates about it. You stay in the team.\nIt leaves this list. Its files and sessions stay.`, 'y/N')
1798
+ : await yes(`Archive ${project.name}`, 'It leaves this list. Its files and sessions stay.', 'y/N');
1799
+ if (!sure) return false;
1800
+ if (teams.length) facade.leaveProject(project.root);
1801
+ if (controller?.repo.root === project.root) await closeProject();
1802
+ archiveProject(project.root); joinedAt = 0;
1803
+ setMessage(teams.length ? `Left ${project.name}. ${names} can no longer ask about it.` : `Archived ${project.name}. n in Projects brings it back.`); return true;
1804
+ }
1805
+ async function closeProject() {
1806
+ rememberDraft(); await controller?.close(); claim.release(); controller = null; claim = { readOnly: true, release() {} }; repo = null;
1807
+ selectedId = null; selectedRequest = null; following = null; usageEnabled = false; vendorList.forget();
1808
+ if (view === 'chat') view = 'tasks';
1809
+ }
1810
+ // + New project: a folder (made when missing; a new or empty one gets Git history), then its name.
1811
+ async function newProjectFlow() {
1812
+ const value = (await prompt('New project', 'Folder for the project. ~ works. A missing folder is created.', ''))?.trim();
1813
+ if (!value) return false;
1814
+ const folder = projectPath(value, startDirectory), fresh = !fs.existsSync(folder);
1815
+ let location = fresh ? createFolder(createFolderPlan(value, startDirectory)) : projectFolder(value, startDirectory);
1816
+ const same = (() => { try { return fs.realpathSync(folder) === location.root; } catch { return false; } })();
1817
+ const empty = same && !fs.readdirSync(location.root).length;
1818
+ if (!location.common && empty && location.root !== fs.realpathSync(os.homedir()) && findBin('git')) { git(location.root, ['init', '--template=', '--initial-branch=main']); location = projectFolder(location.root); }
1819
+ const saved = savedProjectName(location.root);
1820
+ if (saved) { await switchProject(location); setMessage(`Opened ${saved}.`); return true; }
1821
+ return nameHere(location);
1822
+ }
1823
+ const firstLine = (text, width = 48) => clip(String(text ?? '').split('\n').find(line => line.trim()) ?? '', width) || 'none';
1824
+ // A project's settings: instructions and context are edited in place.
1825
+ async function projectSettings(project) {
1826
+ const { root, name } = project, row = (label, value) => ink(label.padEnd(14), 'muted', colour) + ink(value, 'text', colour);
1827
+ // The first row switches to the project; on the active one the list starts on Instructions.
1828
+ const active = controller?.repo.root === root;
1829
+ for (let selected = active ? 1 : 0; ;) {
1830
+ // A file that came with the folder and is not in use says so: its text is shown by Shared files, never in the editor.
1831
+ // One that changed since he approved it shows the approved text, which is the one in force.
1832
+ const settings = readProjectSettings(root), waiting = unusedTrust(root), unused = waiting.filter(file => file.before === null), changed = waiting.length - unused.length;
1833
+ const line = (name, text) => unused.some(file => file.name === name) ? 'not in use' : firstLine(text), count = [unused.length ? `${unused.length} not in use` : '', changed ? `${changed} changed` : ''].filter(Boolean).join(', ');
1834
+ const rows = [active ? ink('● Active project', 'orange', colour) : ink('Make active', 'text', colour), row('Instructions', line('instructions.md', settings.instructions)), row('Context', line('context.md', settings.context)), ink(joinedTeamsOf(root).length ? 'Leave' : 'Archive', 'text', colour), ...(waiting.length ? [row('Shared files', `${count} · Enter to review`)] : [])];
1835
+ const picked = await listDialog(`Project ${name}`, rows, { selected });
1836
+ if (!picked) return;
1837
+ selected = picked.index;
1838
+ if (picked.index === 0) return makeActive(project);
1839
+ else if (picked.index === 1) await editSetting(`Instructions · ${name}`, settings.instructions, text => saveInstructions(root, text));
1840
+ else if (picked.index === 2) await editSetting(`Context · ${name}`, settings.context, text => saveContext(root, text));
1841
+ // Once nothing is left to review the row is gone: the cursor goes back to the top, not to the row that took its place.
1842
+ else if (picked.index === 4) { await reviewProjectFiles(root, { declined: true }); if (!unusedTrust(root).length) selected = active ? 1 : 0; }
1843
+ else if (await archiveRow(project)) return;
1844
+ }
1845
+ }
1846
+ // A project's own settings and instructions are used only after the owner said so on this
1847
+ // machine (lib/project-trust.mjs). For each file that is new or changed he is shown its
1848
+ // text, or what changed, and asked once: use it, or not. Use it is offered once every page
1849
+ // was shown, and never for a file too large to review. While a version he approved is in
1850
+ // force, the choice against is to keep it, and a file that is gone asks whether to go
1851
+ // without. Esc decides later: nothing changes meanwhile, and he is asked again.
1852
+ // declined: also the files he turned down before, from the project's own page.
1853
+ async function reviewProjectFiles(root, { declined = false } = {}) {
1854
+ if (modal || closing) return;
1855
+ // A dialog stops the message box reading; one opened while he was typing gives it back.
1856
+ const said = [], typing = Boolean(ask._reading); let unused = false;
1857
+ for (const file of declined ? unusedTrust(root) : pendingTrust(root)) {
1858
+ const lines = reviewLines(file).flatMap(line => wrapText(line, Math.min(88, screen.width - 4) - 5)), size = Math.max(4, screen.height - 12), pages = Math.ceil(lines.length / size);
1859
+ const place = `.elu-code/${file.name}`, kept = file.before !== null, keeping = `Keeping the approved ${place}`;
1860
+ const yes = file.gone ? 'Go without it' : 'Use it', no = kept ? 'Keep the approved one' : 'Do not use it';
1861
+ const title = `${file.large ? 'Too large' : file.gone ? 'Gone' : file.state === 'new' ? 'New' : file.state === 'changed' ? 'Changed' : 'Not in use'} · ${place}`;
1862
+ let picked, use = false;
1863
+ for (let page = 0, seen = 1; ;) {
1864
+ const all = seen >= pages, rows = [...(file.large ? [] : [all ? yes : `Next page (${page + 1} of ${pages})`]), no, ...(all && pages > 1 ? ['Read it again'] : [])], against = rows.indexOf(no);
1865
+ picked = await listDialog(title, rows, { selected: all ? against : 0, hint: 'enter choose · esc decide later', notes: lines.slice(page * size, (page + 1) * size), shown: true });
1866
+ if (!picked || picked.index === against) break;
1867
+ if (!all) { page += 1; seen = Math.max(seen, page + 1); continue; }
1868
+ if (picked.index === against + 1) { page = 0; seen = 1; continue; }
1869
+ use = true; break;
1870
+ }
1871
+ if (!picked) { said.push(kept ? `${keeping} until you decide` : `${place} is not used until you decide`); break; }
1872
+ // Settings that cannot be read are turned down with the reason, so the project keeps working.
1873
+ if (use && !file.gone && file.name === 'config.yaml') { try { parseConfig(file.text); } catch (error) { answerTrust(root, file, false); said.push(`${kept ? keeping : `Not using ${place}`}: ${String(error.message).replace(/\.$/, '')}`); unused = true; continue; } }
1874
+ answerTrust(root, file, use); said.push(use ? `${file.gone ? 'Going without' : 'Using'} ${place}` : kept ? keeping : `Not using ${place}`); unused ||= !use;
1875
+ }
1876
+ if (!said.length) return;
1877
+ if (typing) setImmediate(() => { if (!modal) focusInput(); });
1878
+ if (controller?.repo.root === root) { controller.reloadConfig(); snapshot = controller.snapshot(); }
1879
+ setMessage(`${said.join('. ')}.${unused ? ' To change that, open Projects and choose this project.' : ''}`);
1880
+ }
1881
+ // The editor shows the text as typed, without the file's last newline. It comes back with
1882
+ // the text when a save is refused, with the reason above the keys.
1883
+ async function editSetting(title, value, save) {
1884
+ let note = ''; value = value.replace(/\n$/, '');
1885
+ for (;;) {
1886
+ const text = await editText(title, value, { note });
1887
+ if (text === null) return;
1888
+ try { save(text); setMessage('Saved.'); return; } catch (error) { note = error.message; value = text; }
1889
+ }
1890
+ }
1891
+ // Kanban keys act on the selected task in a column.
1892
+ async function kanbanAction(index, action) {
1893
+ if (!controller && (action !== 'new' || !await createHere())) { if (!controller) throw new Error(NO_PROJECT); return; }
1894
+ const column = kanbanData[index], item = column?.items[kanbanLists[index].selected ?? 0], repo = controller.repo;
1895
+ if (action === 'new') {
1896
+ const text = await prompt('New task', 'What should be done? It waits in To do until you start it with Shift+→.', '');
1897
+ if (!text?.trim()) return; addCard(repo, text); setMessage('Added to To do. Shift+→ on it starts it.'); return;
1898
+ }
1899
+ if (!item) throw new Error('Nothing here. n adds a task to To do.');
1900
+ if (action === 'open') {
1901
+ if (item.card?.column === 'todo') {
1902
+ const text = await prompt('Edit task', 'Change the task. Shift+→ on it starts it.', item.card.text);
1903
+ if (text?.trim() && text.trim() !== item.card.text) { editCard(repo, item.card.id, text); setMessage('Saved.'); } return;
1904
+ }
1905
+ if (item.task) return openRow({ kind: 'task', root: repo.root, id: item.task.id });
1906
+ if (item.request) return openRow({ kind: 'request', root: repo.root, request: item.request });
1907
+ throw new Error('Its session is not available. x removes it from the board.');
1908
+ }
1909
+ if (action === 'remove') {
1910
+ if (!item.card) throw new Error('Move it to Done instead. Its session stays in Sessions.');
1911
+ removeCard(repo, item.card.id); setMessage('Removed from the board.'); return;
1912
+ }
1913
+ if (action === 'forward') {
1914
+ if (column.column === 'todo') {
1915
+ controller.writable();
1916
+ const request = enqueue(repo.root, item.card.text, { priority: 0, permissions: defaultMode() }); startCard(repo, item.card.id, request.id); conduct();
1917
+ setMessage('Started. It moves to Needs you when the model stops.'); return;
1918
+ }
1919
+ if (column.column === 'progress') throw new Error('It is being worked on. It moves to Needs you when the model stops.');
1920
+ if (column.column === 'needs') {
1921
+ if (!item.card && !item.task) throw new Error('Enter opens it: send it again, or /stop cancels it.');
1922
+ setDone(repo, item, true); setMessage('Moved to Done.'); return;
1923
+ }
1924
+ throw new Error('Done is the last column. Shift+← reopens it.');
1925
+ }
1926
+ if (action === 'back') {
1927
+ if (column.column === 'done') {
1928
+ if (!item.card && !readBoard(repo).done.includes(item.task.id)) throw new Error('Answered questions and published work stay in Done.');
1929
+ setDone(repo, item, false); setMessage('Reopened.'); return;
1930
+ }
1931
+ if (column.column === 'todo') throw new Error('To do is the first column.');
1932
+ throw new Error('A started task stays with its session. Enter opens it; Shift+→ moves it to Done.');
1933
+ }
1934
+ }
1935
+ // When the New task form's message goes: now, or on a schedule.
1936
+ async function setWhen() {
1937
+ const answer = await prompt('When', ['When should this message be sent?', '', 'now send it now', 'daily 9:00 every day', 'weekdays 9am Monday to Friday', 'mon,thu 14:30 chosen days', 'every 2h every 2 hours (15m to 7d)', 'tomorrow 15:00 once', '2026-12-01 9:00 once', '', 'Times are this computer’s local time. Each run starts a new session.'].join('\n'), composeWhen?.text ?? '');
1938
+ if (answer === null) return;
1939
+ const when = parseWhen(answer);
1940
+ composeWhen = when && { ...when, text: answer.trim() };
1941
+ // A schedule runs unattended, so it never gets Full access.
1942
+ const capped = when && composeMode === 'full'; if (capped) composeMode = 'auto';
1943
+ setMessage(when ? `Runs ${when.label}, next ${dateText(nextRun(when))}. ${capped ? SCHEDULE_CAP : 'Write the message below.'}` : 'Sends now.');
1944
+ focusInput();
1945
+ }
1946
+ async function scheduleTask(text) {
1947
+ const row = saveSchedule({ root: controller.repo.root, project: snapshot.name, goal: text.trim(), when: composeWhen, permissions: composeMode === 'full' ? 'auto' : composeMode, ...(composeModel ? { model: composeModel } : {}) });
1948
+ composeWhen = null; drafts.delete(draftKey()); ask.clearValue(); refreshAwake();
1949
+ let note = '';
1950
+ try { const result = loginItem.install(); if (!result.installed) note = ` ${result.reason}`; }
1951
+ catch (error) { note = ` It runs only while ELU Code is open: ${error.message}`; }
1952
+ showView('tasks'); focusInput();
1953
+ setMessage(`Scheduled ${row.when.label}, next ${dateText(new Date(row.nextRunAt))}.${note}`);
1954
+ }
1955
+ // r runs it now, p pauses or resumes it, x deletes it, Enter opens its last run.
1956
+ async function scheduleAction(row, action) {
1957
+ const { schedule } = row;
1958
+ if (action === 'open') {
1959
+ const task = schedule.lastTaskId && snapshot.projects.find(project => project.root === row.root)?.tasks?.find(value => value.id === schedule.lastTaskId);
1960
+ if (!task) throw new Error(schedule.runs ? 'Its last run is not in Sessions any more. r runs it now.' : 'No runs yet. r runs it now.');
1961
+ return openRow({ kind: 'task', root: row.root, id: task.id });
1962
+ }
1963
+ if (action === 'retry') { runScheduleNow(schedule.id, new Date(), { start: conduct }); setMessage('Running it now. It shows under Queued, then Running.'); return; }
1964
+ if (action === 'publish') { const value = pauseSchedule(schedule.id, schedule.enabled); refreshAwake(); setMessage(value.enabled ? `Resumed. Next run ${dateText(new Date(value.nextRunAt))}.` : 'Paused. p resumes it.'); return; }
1965
+ if (action === 'stop') {
1966
+ if (!await yes('Delete schedule', `${schedule.when.label} · ${projectName(row.root)}\n${schedule.goal}\n\nPast runs stay in Sessions.`)) return;
1967
+ if (!removeSchedule(schedule.id) && !botSchedulesOn()) { try { loginItem.uninstall(); } catch { /* The login item finds nothing to run. */ } }
1968
+ refreshAwake(); setMessage('Schedule deleted.'); return;
1969
+ }
1970
+ throw new Error('r runs it now, p pauses it, x deletes it.');
1971
+ }
1972
+ // Picks a known project by number, or opens or creates a folder by path.
1973
+ async function chooseProject() {
1974
+ const projects = allProjects().projects.filter(project => !project.unavailable);
1975
+ const value = (await prompt('Project', [...projects.map((project, index) => `${String(index + 1).padStart(2)} ${project.name} ${project.root}`), '', projects.length ? 'Type a number, or a folder to open. A folder that does not exist yet is created.' : 'Type a folder to open. A folder that does not exist yet is created.', 'No Git or setup is needed.'].join('\n'), ''))?.trim();
1976
+ if (!value) return false;
1977
+ if (/^[0-9]+$/.test(value)) { const chosen = projects[Number(value) - 1]; if (!chosen) throw new Error(`Choose 1 to ${projects.length}, or type a folder.`); await switchProject(projectFolder(chosen.root)); return true; }
1978
+ const folder = path.resolve(startDirectory, value.replace(/^~(?=$|\/)/, process.env.HOME ?? '~'));
1979
+ return openProject(value, !fs.existsSync(folder));
1980
+ }
1981
+ async function composeAction(row, action = 'open') {
1982
+ if (!row) return;
1983
+ if (row.type === 'project') { if (await chooseProject()) { selectedId = null; selectedRequest = null; resize(); } return; }
1984
+ if (row.type === 'when') { if (action === 'remove') { composeWhen = null; setMessage('Sends now.'); return; } return setWhen(); }
1985
+ // The session's permission rows: mode, where it runs, extra folders.
1986
+ if (row.type === 'permissions') {
1987
+ const mode = await pickMode({ schedule: Boolean(composeWhen) }); if (!mode) return;
1988
+ composeMode = mode; if (composePlace.kind === 'here' && mode !== 'full') composePlace = { kind: 'worktree' };
1989
+ setMessage(`${LABELS[mode]} for this session.`); return;
1990
+ }
1991
+ if (row.type === 'place') { const place = await pickPlace({ mode: composeMode }); if (place) { composePlace = place; setMessage(`${placeText(place)} for this session.`); } return; }
1992
+ if (row.type === 'folders') {
1993
+ if (action === 'remove') { composeFolders = []; setMessage('No extra folders.'); return; }
1994
+ const folders = await pickFolders(composeFolders); if (folders) { composeFolders = folders; setMessage(folders.length ? `The model reads ${folders.map(shortHome).join(', ')} this session.` : 'No extra folders.'); } return;
1995
+ }
1996
+ if (row.type === 'model') { if (action === 'remove') return; const now = composeModel ?? controller.defaultModel(), choice = await pickModel(now, `The new session uses ${nameOf(now)} (${modelText(now)}).`); if (choice) useModel(choice); return; }
1997
+ if (row.type === 'bots') { if (action === 'remove') { composeBots = []; setMessage('No bots in this session.'); return; } return pickBots(); }
1998
+ }
1999
+ // The Model picker: every model a session can be given, narrowed to what is typed. A name
2000
+ // that is not listed is taken as vendor:name. null when closed with nothing chosen.
2001
+ async function pickModel(current, said) {
2002
+ const choices = modelChoices(), rows = choices.map(row => ({ name: row.name, note: [row.vendor, row.note].filter(Boolean).join(' · ') }));
2003
+ if (!rows.length) throw new Error('No vendor command line is installed, so there is no model to choose yet.');
2004
+ const check = text => { if (!text.trim()) return null; try { findModel(text); return null; } catch (error) { return error.message; } };
2005
+ const answer = await prompt('Model', `${said}\nChoose one, or type vendor:name for a model that is not listed.`, choiceName(current, choices), { suggest: text => matchModels(rows, text), check });
2006
+ return answer?.trim() ? findModel(answer) : null;
2007
+ }
2008
+ // Gives a model to the open session, from its next message, or to the new session form.
2009
+ function useModel(choice) {
2010
+ const task = openTask();
2011
+ if (task) { controller.setModel(task.id, choice); reported.at = 0; setMessage(`${nameOf(choice)} from your next message.`); return; }
2012
+ if (!composing()) throw new Error('Open a session, or /new. /model default <name> sets new sessions.');
2013
+ composeModel = choice; setMessage(`${nameOf(choice)} for this session.`);
2014
+ }
2015
+ // /model add: a vendor that can run a session here, then a model by the name its command
2016
+ // line knows. A name the vendor does not list is remembered, so /model offers it later.
2017
+ async function addModel() {
2018
+ const vendors = installedVendors();
2019
+ if (!vendors.length) throw new Error('No vendor command line is installed, so there is no model to add yet.');
2020
+ let vendor = vendors[0];
2021
+ if (vendors.length > 1) {
2022
+ let rows = []; try { rows = cachedVendors(); } catch { rows = []; }
2023
+ const picked = await listDialog('Add a model · runs on', vendors.map(name => { const row = rows.find(item => item.vendor === name); return ink(name.padEnd(8), 'text', colour, true) + ink(row ? vendorText(row) : VENDOR_NAMES[name], 'muted', colour); }));
2024
+ if (!picked) return;
2025
+ vendor = vendors[picked.index];
2026
+ }
2027
+ const models = botModels(vendor), listed = name => models.some(row => row.name === name);
2028
+ const check = text => text.trim() ? modelProblem(vendor, text.trim()) : 'Name the model.';
2029
+ const answer = (await prompt('Add a model', `A model ${VENDOR_NAMES[vendor]} knows by name. /model offers it from now on.`, '', models.length ? { check, suggest: text => matchModels(models, text), warn: text => listed(text.trim()) ? null : `${text.trim()} is not a model ${vendor} lists.` } : { check }))?.trim();
2030
+ if (!answer) return;
2031
+ const choice = listed(answer) ? { vendor, model: answer } : rememberModel(vendor, answer);
2032
+ if (openTask() || composing()) return useModel(choice);
2033
+ setMessage(`Added ${nameOf(choice)} (${modelText(choice)}). /model ${choiceName(choice, modelChoices())} uses it in a session.`);
2034
+ }
2035
+ // /model shows or chooses the model of the open session, or of the new session form.
2036
+ // default <name> sets what new sessions of this project start with; add adds one by name.
2037
+ async function modelCommand(args) {
2038
+ if (!controller) throw new Error(NO_PROJECT);
2039
+ if (args[0] === 'add') { if (args.length > 1) throw new Error('Use /model add.'); return addModel(); }
2040
+ if (args[0] === 'default') {
2041
+ if (args.length > 2) throw new Error('Use /model default <name>.');
2042
+ const before = controller.defaultModel(), choice = args[1] ? findModel(args[1]) : await pickModel(before, `New sessions in ${controller.snapshot().name} start with ${nameOf(before)} (${modelText(before)}).`);
2043
+ if (!choice) return;
2044
+ const saved = controller.setDefaultModel(choice);
2045
+ setMessage(`New sessions in ${controller.snapshot().name} start with ${nameOf(saved)} (${modelText(saved)}).`); return;
2046
+ }
2047
+ if (args.length > 1) throw new Error('Use /model, /model <name>, /model default <name> or /model add.');
2048
+ const task = openTask(), now = modelNow();
2049
+ if (!task && !composing()) {
2050
+ if (args[0]) throw new Error('Open a session, or /new. /model default <name> sets new sessions.');
2051
+ setMessage(`New sessions start with ${nameOf(now)} (${modelText(now)}). /model default <name> changes it.`); return;
2052
+ }
2053
+ const choice = args[0] ? findModel(args[0]) : await pickModel(now, `${task ? `This session uses ${modelOf(task)}` : `The new session uses ${nameOf(now)}`} (${modelText(now)}).`);
2054
+ if (choice) useModel(choice);
2055
+ }
2056
+ // Edits that are not verified are checked first. A project with no checks of its own is asked
2057
+ // for its test or build command, and asked again while the one given here fails; a project's
2058
+ // own checks are run as they are. What is still not verified after that is refused, as before.
2059
+ // checkAsked: the command last given at that prompt, by session, while the app runs.
2060
+ const checkAsked = new Map();
2061
+ async function publishCurrent() {
2062
+ if (!controller || !selectedId || selectedRequest) throw new Error('Open finished editing work from Sessions first.');
2063
+ const id = selectedId, open = controller.snapshot().tasks.find(row => row.id === id);
2064
+ if (open?.state === 'unverified') {
2065
+ const results = open.evidence?.results ?? [], key = `${controller.repo.root}\0${id}`;
2066
+ let command;
2067
+ const again = checkAsked.has(key) && results.some(row => row.status === 'failed');
2068
+ if (again || results.some(row => row.name === 'project-checks' && row.status === 'not-configured')) {
2069
+ const value = await prompt('Verify edits', 'Test or build command. Enter words separated by spaces, or a JSON argument array.', checkAsked.get(key) ?? controller.draftConfig.checks[0]?.command.join(' ') ?? 'node --test');
2070
+ if (!value?.trim()) { setMessage('Not published. The edits are still not verified.'); return; }
2071
+ command = value.trim().startsWith('[') ? JSON.parse(value) : commandWords(value); checkAsked.set(key, value.trim());
2072
+ }
2073
+ setMessage('Running the checks…');
2074
+ await controller.verify(id, command);
2075
+ setMessage('');
2076
+ }
2077
+ const plan = await controller.pushPlan(id), task = controller.snapshot().tasks.find(row => row.id === id);
2078
+ // Work done with Full access says so before it goes out.
2079
+ const full = task?.permissions === 'full' || (task?.runs ?? []).some(run => run.role === 'lead' && run.permissions === 'full') ? `${modelOf(task)} ran with Full access.\n` : '';
2080
+ if (await yes('Push and open draft PR', `Commit: ${plan.commit}\nDestination: ${plan.destination.url}\nRef: ${plan.destination.ref}\nBase: ${plan.destination.base}\n\n${plan.title}\n${full}\nPush and open a draft PR with the evidence? [y/N]`)) { const result = await controller.push(id, plan.confirmationId); setMessage(`Pushed ${result.commit}; draft PR ${result.pr}`); }
2081
+ }
2082
+ // Closing hides a session from Sessions and puts it in Kanban's Done; its history and
2083
+ // worktree stay. A new message opens it again.
2084
+ function closeTask(location, task, after) {
2085
+ if (['running', 'preparing'].includes(task.state)) throw new Error(`${sessionName(task)} is running. Stop it first with /stop.`);
2086
+ closeSession(location, task.id); after?.();
2087
+ setMessage(`Archived ${sessionName(task)}. /archived lists it; a new message brings it back.`);
2088
+ }
2089
+ // Asks for a session's name: one line of up to TITLE_MAX characters. The box opens with
2090
+ // the name only when someone chose it (the owner, an approved Task, the model), never
2091
+ // with the request. A name that is refused stays in the box. An empty answer takes the
2092
+ // owner's name away: the session goes back to its Task's title, the model's name, or the
2093
+ // first words of what was asked.
2094
+ async function renameTask(location, task, given = null) {
2095
+ const own = typeof task.title === 'string' ? task.title.trim() : '';
2096
+ for (let typed = own.length <= TITLE_MAX ? own : ''; ;) {
2097
+ const answer = given ?? await prompt('Rename session', `One line of up to ${TITLE_MAX} characters. It shows in the list and the title. Leave it empty to go back to the name ELU Code gave it.`, typed, { check: nameProblem });
2098
+ if (answer == null) return;
2099
+ try {
2100
+ const saved = setTitle(location, task.id, answer);
2101
+ setMessage(saved.titleBy === 'owner' ? `Renamed to ${saved.title}.` : saved.title ? `The session is named ${saved.title} again.` : 'The session is named after your first message again.'); return;
2102
+ } catch (error) { if (given != null) throw error; setMessage(error.message); typed = answer; }
2103
+ }
2104
+ }
2105
+ async function stopCurrent(given) {
2106
+ if (shellRun) { shellRun.run.stop(); setMessage('Stopping the command.'); return; }
2107
+ if (!controller) throw new Error(NO_PROJECT);
2108
+ controller.writable(); const root = controller.repo.root;
2109
+ if (selectedRequest) {
2110
+ // It may have started since the screen last drew: then its session is open, and the turn below is what stops.
2111
+ let row = openStarted(), taken = null;
2112
+ // A message that still waits is taken back in one step. One the conductor has begun to
2113
+ // start is not cancelled: its session would start all the same, after the owner was
2114
+ // told it was cancelled. In a moment it is a session, and /stop stops its turn.
2115
+ if (selectedRequest && row?.state === 'queued') { taken = takeRequest(root, row.id); if (!taken) row = openStarted(); }
2116
+ if (selectedRequest && !taken && ['queued', 'starting'].includes(row?.state)) throw new Error('This session is still starting. /stop again in a moment.');
2117
+ if (selectedRequest) { if (!taken) controller.cancelRequest(selectedRequest.id); following = null; selectedRequest = null; ask.clearValue(); setMessage('Request cancelled.'); return; }
2118
+ }
2119
+ const id = given ?? selectedId;
2120
+ const task = id && controller.snapshot().tasks.find(task => task.id === String(id));
2121
+ const rows = id ? pendingRequests(root) : [], pending = rows.filter(row => row.continuationTaskId === String(id) && row.state === 'queued');
2122
+ const cancel = () => { const gone = pending.filter(row => takeRequest(root, row.id)).length; return gone > 1 ? 'Waiting messages cancelled.' : gone ? 'Waiting message cancelled.' : null; };
2123
+ // A turn of this session the conductor is starting this instant: its request says so
2124
+ // before the session says it is working. The stop is sent all the same, and the conductor
2125
+ // reads it at its next look, when the turn is there to stop.
2126
+ const starting = rows.find(row => row.continuationTaskId === String(id) && row.state === 'starting');
2127
+ if (task && (['running', 'preparing'].includes(task.state) || starting)) {
2128
+ // The turn that is being stopped: its request, or the time it began when it has none.
2129
+ const turn = `${root}\0${task.id}\0${starting?.id ?? rows.find(row => row.taskId === task.id && row.state === 'running')?.id ?? task.activeSince ?? ''}`;
2130
+ // A second /stop while the same turn is still stopping cancels what waits behind it, so the session is left idle.
2131
+ if (stopAsked === turn && pending.length) { setMessage(cancel() ?? 'Stopping.'); return; }
2132
+ stopAsked = turn; sendCommand({ type: 'stop', root, taskId: task.id }); conduct();
2133
+ setMessage(pending.length ? `Stopping. Your waiting message${pending.length > 1 ? 's are' : ' is'} sent next · /stop again cancels ${pending.length > 1 ? 'them' : 'it'}.` : 'Stopping. Changes and chat history are kept.'); return;
2134
+ }
2135
+ const cancelled = cancel();
2136
+ if (!cancelled) throw new Error('Nothing is running in this chat.');
2137
+ setMessage(cancelled);
2138
+ }
2139
+ // The newest message the owner sent to a session that has not started yet: the one Up in
2140
+ // the message box takes back. A bot's scheduled turn is not his to take back here.
2141
+ function waitingMessage(id, rows) {
2142
+ return rows.filter(row => row.continuationTaskId === String(id) && row.state === 'queued' && !row.scheduled && !row.origin)
2143
+ .sort((a, b) => (a.priority ?? 1) - (b.priority ?? 1) || Date.parse(a.queuedAt ?? a.createdAt) - Date.parse(b.queuedAt ?? b.createdAt)).at(-1) ?? null;
2144
+ }
2145
+ // Takes the open session's newest waiting message back and returns it as it was typed,
2146
+ // or null when none waits or it has just been sent.
2147
+ function takeBack() {
2148
+ if (!controller || claim.readOnly || view !== 'chat' || !selectedId || selectedRequest) return null;
2149
+ let row = null;
2150
+ try { const root = controller.repo.root; row = waitingMessage(selectedId, pendingRequests(root)); row &&= takeRequest(root, row.id); } catch { return null; }
2151
+ return row ? typedGoal(row) : null;
2152
+ }
2153
+ async function showDetails(location, task) {
2154
+ const data = taskDetails(location, task.id);
2155
+ const response = task.reply ?? task.result;
2156
+ await prompt('Session details: diff / checks / review / log', [task.goal, `State: ${task.state}`, `PR: ${task.pr ?? 'not published'}`, `Question: ${response?.question ?? 'none'}`, task.error?.message ?? '', '\nRESPONSE', response?.summary ?? '', '\nREQUESTS', ...(task.messages ?? []).map(row => row.bot ? `@${row.bot}: ${row.text}` : row.to ? `You → ${row.to}: ${row.text}` : row.text), '\nDIFF', data.diff || '(no diff)', '\nCHECKS', ...data.checks.map(row => `${row.name}: ${row.status} ${(row.details ?? []).join('; ')}`), '\nREVIEW', ...data.reviews.flatMap(row => [`${roleLabel(row.role ?? 'reviewer')} round ${row.round}: ${row.result.summary}`, ...row.result.comments.map(comment => `${comment.severity}: ${comment.body}`)]), '\nLOG', ...data.logs.map(log => `${roleLabel(log.role)}:\n${log.text}`)].join('\n'));
2157
+ }
2158
+ // Opens a row's chat and moves to its message box; a blocked or saved request comes back into the box.
2159
+ async function openRow(row) {
2160
+ const from = view;
2161
+ await switchProject(projectFolder(row.root));
2162
+ if (!controller) return;
2163
+ if (from !== 'chat') returnTo = from === 'kanban' ? 'kanban' : 'tasks';
2164
+ rememberDraft(); message = '';
2165
+ showSession(row.kind === 'task' ? { id: row.id } : { id: row.request.continuationTaskId ?? null, request: row.request }, true);
2166
+ // In Sessions the list's cursor is on this row, and the right side already shows it.
2167
+ if (returnTo === 'tasks') { barKey = openKey = row.key; ownSeen = ownKey(); wanted = null; }
2168
+ resize(); focusInput();
2169
+ }
2170
+ // Task keys act on the selected row in any project without opening it.
2171
+ async function boardAction(action) {
2172
+ const row = selectedBoardRow();
2173
+ // The row of a session whose Task waits takes y and n, once no approval of an action waits there.
2174
+ if (['accept', 'decline'].includes(action) && taskWaits(row) && !rowApproval(row)) return decideTask(row.root, row.task, action === 'accept');
2175
+ // An approval the lead waits on: y allows, n denies, Enter shows it.
2176
+ if (row?.kind === 'approval') {
2177
+ const { approval } = row, summary = approval.summary, session = approvalSession(approval);
2178
+ if (action === 'full') return offersFull(session, approval) ? allowFull(session, approval, rowProject(row)) : undefined;
2179
+ if (action === 'open') {
2180
+ const key = (await prompt('Needs you', `${approval.role && approval.role !== 'lead' ? roleLabel(approval.role) : row.task && row.root === controller?.repo.root ? modelOf(row.task) : 'The model'} wants to:\n${summary}\n\n${approvalKeys(session, approval)} · esc later`, '', { keys: offersFull(session, approval) ? ['y', 'n', 'f'] : ['y', 'n'] }))?.trim().toLowerCase();
2181
+ if (key === 'f' && offersFull(session, approval)) return allowFull(session, approval, rowProject(row));
2182
+ if (!['y', 'n'].includes(key)) { if (key) throw new Error('Nothing was done. Press y or n.'); return; }
2183
+ action = key === 'y' ? 'accept' : 'decline';
2184
+ }
2185
+ if (action === 'accept' || action === 'decline') { setMessage(answerApproval(approval.id, action === 'accept' ? 'allow' : 'deny') ? action === 'accept' ? 'Allowed.' : 'Denied.' : 'That approval was already answered.'); return; }
2186
+ throw new Error('y allows it, n denies it, Enter shows it.');
2187
+ }
2188
+ // The row of a session that waits on an approval takes y and n too, as its chat says: Esc, then y or n.
2189
+ if (action === 'full') { const asked = rowApproval(row); return asked && offersFull(row.task, asked) ? allowFull(row.task, asked, rowProject(row)) : undefined; }
2190
+ const waiting = ['accept', 'decline'].includes(action) && rowApproval(row);
2191
+ if (waiting) { setMessage(answerApproval(waiting.id, action === 'accept' ? 'allow' : 'deny') ? action === 'accept' ? 'Allowed.' : 'Denied.' : 'That approval was already answered.'); return; }
2192
+ if (['accept', 'decline'].includes(action)) {
2193
+ const decision = action === 'accept' ? 'answer' : 'decline';
2194
+ if (peerDecision(row)) return decidePeer(row, decision);
2195
+ if (action === 'accept') throw new Error('y works on teammate messages, replies, and drafts.');
2196
+ if (row?.kind === 'peer') return decidePeer(row, 'decline');
2197
+ action = 'new';
2198
+ }
2199
+ // Enter on + New session goes into the form as it stands. n and + start it over.
2200
+ if (action === 'open' && boardLines[board.selected ?? 0]?.add && composing()) return focusInput();
2201
+ if (action === 'new' || action === 'open' && boardLines[board.selected ?? 0]?.add) return newTaskForm();
2202
+ if (!row) return;
2203
+ if (row.kind === 'schedule') return scheduleAction(row, action);
2204
+ const live = row.kind === 'task' && ['running', 'preparing'].includes(row.task.state);
2205
+ if (row.kind === 'peer') {
2206
+ if (action !== 'open') throw new Error('Enter reads it. n dismisses it.');
2207
+ const key = await peerDetails({ type: 'thread', ...row.item }), decision = { y: 'answer', w: 'work', n: 'decline' }[key];
2208
+ return decision ? decidePeer(row, decision) : undefined;
2209
+ }
2210
+ // A saved request from a teammate opens like its thread: y accepts, n declines.
2211
+ if (row.kind === 'request' && action === 'open' && peerIncoming(row)) {
2212
+ const key = await peerDetails({ type: 'thread', kind: 'incoming', ...(row.item ?? {}), id: row.item?.id ?? row.request.origin?.a2aTaskId, root: row.root, requestId: row.request.id, text: row.item?.text ?? row.request.origin?.context ?? row.request.goal, project: row.project, name: row.request.origin?.name, peer: row.request.origin?.peer, request: row.request.origin?.kind, trusted: row.item?.trusted === true }), decision = { y: 'answer', w: 'work', n: 'decline' }[key];
2213
+ return decision ? decidePeer(row, decision) : undefined;
2214
+ }
2215
+ if (action === 'open') return openRow(row);
2216
+ if (action === 'stop') {
2217
+ if (row.kind === 'request') { removeRequest(row.root, row.request.id); if (row.request.origin) await peerCommand({ type: 'discard', id: row.item?.id ?? row.request.origin.a2aTaskId }).catch(() => {}); setMessage('Queued request cancelled.'); return; }
2218
+ if (!live) throw new Error(row.task ? `${sessionName(row.task)} is not running.` : 'That session is not running.');
2219
+ sendCommand({ type: 'stop', root: row.root, taskId: row.id }); conduct(); setMessage(`Stopping ${sessionName(row.task)}. Changes and chat history are kept.`); return;
2220
+ }
2221
+ if (action === 'retry') {
2222
+ if (row.kind === 'request') { if (!retryRequest(row.root, row.request.id)) throw new Error('Only a blocked or saved request can be retried.'); conduct(); setMessage('Queued again.'); return; }
2223
+ if (!['blocked', 'stopped', 'unverified'].includes(row.task.state)) throw new Error('Retry works on blocked, stopped, or unverified work.');
2224
+ // The last message to the lead goes again. A session that only wrote to a bot sends that message to the bot again.
2225
+ const yours = (row.task.messages ?? []).filter(value => !value.bot), last = yours.findLast(value => !value.to) ?? yours.at(-1);
2226
+ enqueue(row.root, last?.text ?? row.task.goal, { continuationTaskId: row.id, priority: 0, ...(last?.to ? { bot: last.to.replace(/^@/, '') } : {}) }); conduct(); setMessage(`Retrying ${sessionName(row.task)}.`); return;
2227
+ }
2228
+ if (action === 'answer') {
2229
+ if (row.kind !== 'task') throw new Error('Choose a session to answer.');
2230
+ const response = row.task.reply?.outcome === 'needs_input' ? row.task.reply : row.task.result;
2231
+ const body = row.task.state === 'needs_input' ? `${response?.question ?? ''}\n${(response?.choices ?? []).join(' / ')}` : `Follow up in ${row.project}:\n${row.task.goal}${live ? '\n\nIt is sent when the current turn ends.' : ''}`;
2232
+ const text = await prompt(`Answer ${sessionName(row.task)}`, body, ''); if (!text?.trim()) return;
2233
+ enqueue(row.root, text.trim(), { continuationTaskId: row.id, priority: 0 }); conduct(); setMessage(`Sent to ${sessionName(row.task)}.`); return;
2234
+ }
2235
+ // d shows what the session changed, beside the list, and moves into it.
2236
+ if (action === 'diff') {
2237
+ if (row.kind !== 'task') throw new Error('Choose a session to see its changes.');
2238
+ if (view !== 'chat' || selectedId !== row.id || controller?.repo.root !== row.root) await openRow(row);
2239
+ showChanges({ focus: true }); changesFrom = board; return;
2240
+ }
2241
+ if (action === 'close') {
2242
+ if (row.kind !== 'task') throw new Error('c archives a session. Choose one.');
2243
+ if (row.archived) return setMessage(`${sessionName(row.task)} is archived already. u brings it back.`);
2244
+ return closeTask(projectFolder(row.root), row.task);
2245
+ }
2246
+ if (action === 'rename') { if (row.kind !== 'task') throw new Error('e renames a session. Choose one.'); return renameTask(projectFolder(row.root), row.task); }
2247
+ if (action === 'unarchive') {
2248
+ if (row.kind !== 'task' || !row.archived) return;
2249
+ reopenSession(projectFolder(row.root), row.id); barKey = row.key; setMessage(`${sessionName(row.task)} is back in the list.`); return;
2250
+ }
2251
+ if (action === 'publish') {
2252
+ if (row.kind !== 'task' || row.task.state !== 'ready') throw new Error('Only ready work can be published. Ready rows show checks and review.');
2253
+ await openRow(row); return publishCurrent();
2254
+ }
2255
+ }
2256
+ async function dispatch(command) {
2257
+ const { name, args } = command;
2258
+ if (args.length && ['new', 'schedule', 'sessions', 'kanban', 'chats', 'projects', 'peers', 'usage', 'changes', 'chat', 'details', 'publish', 'archive', 'archived', 'setup', 'help', 'quit'].includes(name)) throw new Error(`Use ${command.usage}.`);
2259
+ if (name === 'quit') return quit();
2260
+ if (name === 'help') return prompt('Commands', ['Six tabs: Sessions, Projects, Kanban, Chats, Bots, and Usage. Tab moves between areas; arrows move inside one.', 'Sessions: every session, in a list on the left. The selected one shows beside it. Enter moves into it and Esc moves back. d (or /changes) shows what it changed in place of its chat; Esc goes back. n or + New session starts one. c (or /close) closes one.', 'Projects: every project; ● marks the active one. o makes another active; Enter opens its settings; n or + New project creates one.', 'Kanban: this project’s tasks. n adds one; Shift+→ on a To do task starts it.', 'Chats: channels and direct messages on the left, the open one’s threads beside them. Type to write to it. n makes a channel, m sets who is in it, /invite @name adds one teammate, x leaves it.', 'Bots: models with instructions you write once. n or + New bot makes one; Enter opens it; x removes it. Each shows in its own color; /bots color NAME COLOR changes it. In a session, @name alone adds one and @name with a message asks it; a teammate’s @name with a message asks them.', 'Usage: your accounts and their plan usage. Enter shows a vendor’s details; r checks again.', 'Schedules: in + New session, set When (daily 9:00, every 2h, ...). r runs one now, p pauses, x deletes.', 'Permissions: each session runs as Ask for approval, Approve for me, or Full access. /permissions changes it.', 'Model: each session has its own. /model changes it, /model default sets what new sessions start with, and /model add adds one by name.', 'Agents: /agent add <model> puts another model in this session with no bot to write first. Words after the model say what it is for (/agent add sonnet you review my SQL), and --name NAME names it. @name with a message asks it; /agent remove <name> takes it out.', 'Type / to filter commands. Tab completes; Enter runs.', 'Start a message with ! to run a shell command in the project folder: !git status. In an open session the model sees it with your next message.', 'Mouse: drag to copy; a double click copies a word. Click in the message box to move the cursor.', '', ...COMMANDS.map(row => `${row.usage}\n ${row.description}`), '', 'Examples:', '/model sonnet', '/model default opus', '/permissions auto', '/permissions folders add ~/evals', '/close'].join('\n'));
2261
+ if (name === 'model') return modelCommand(args);
2262
+ // /permissions [mode] sets this session's mode; default <mode> sets what new sessions start with.
2263
+ if (name === 'budget') {
2264
+ const task = controller && view === 'chat' && !selectedRequest && selectedId ? controller.snapshot().tasks.find(row => row.id === selectedId) : null;
2265
+ if (!task) throw new Error('Open a session to see or change its spending limit.');
2266
+ if (isPeerRun(task.origin)) throw new Error(READ_ONLY_PEERS);
2267
+ if (args.length > 1) throw new Error(`Use ${command.usage}.`);
2268
+ const money = value => `$${Number(value).toFixed(2)}`;
2269
+ if (!args.length) { const { spent, limit } = controller.budget(task.id); setMessage(limit ? `This session has spent ${money(spent)} of its ${money(limit)} limit. /budget off removes the limit.` : `This session has spent ${money(spent)}. It has no limit; /budget NUMBER sets one.`); return; }
2270
+ if (/^(off|none)$/i.test(args[0])) { controller.setBudget(task.id, null); setMessage(`This session has no spending limit.${task.state === 'blocked' ? ' Send your message again.' : ''}`); return; }
2271
+ if (!/^[$]?[0-9]+([.][0-9]+)?$/.test(args[0])) throw new Error('Use /budget with a number of dollars, such as /budget 20, or /budget off.');
2272
+ const dollars = Number(args[0].replace('$', ''));
2273
+ controller.setBudget(task.id, dollars);
2274
+ setMessage(`This session may now spend up to ${money(dollars)}.${task.state === 'blocked' ? ' Send your message again.' : ''}`); return;
2275
+ }
2276
+ if (name === 'permissions') {
2277
+ // /permissions folders add <path>: one more folder in your home the model may read this session.
2278
+ if (args[0] === 'folders') {
2279
+ if (args[1] !== 'add' || args.length !== 3) throw new Error('Use /permissions folders add <path>.');
2280
+ const task = controller && view === 'chat' && !selectedRequest && selectedId ? controller.snapshot().tasks.find(row => row.id === selectedId) : null;
2281
+ const composing = controller && view === 'chat' && !selectedId && !selectedRequest;
2282
+ if (!task && !composing) throw new Error('Open a session, or /new, then /permissions folders add <path>.');
2283
+ if (task && isPeerRun(task.origin)) throw new Error(READ_ONLY_PEERS);
2284
+ const dir = expandHome(args[2]);
2285
+ if (composing) { composeFolders = checkFolders([...composeFolders, dir]); setMessage(`The model reads ${shortHome(dir)} this session.`); return; }
2286
+ controller.addFolder(task.id, dir); setMessage(`Added ${shortHome(dir)}. The model reads it from your next message.`); return;
2287
+ }
2288
+ if (args[0] === 'default') { if (args.length !== 2) throw new Error('Use /permissions default <ask|auto|full>.'); const mode = checkMode(args[1], { allowFull: true }); saveDefaultMode(mode); setMessage(`New sessions start with ${LABELS[mode]}.`); return; }
2289
+ if (args.length > 1 || args[0] && !MODES.includes(args[0])) throw new Error(`Use ${command.usage}.`);
2290
+ const task = controller && view === 'chat' && !selectedRequest && selectedId ? controller.snapshot().tasks.find(row => row.id === selectedId) : null;
2291
+ const composing = controller && view === 'chat' && !selectedId && !selectedRequest;
2292
+ if (!task && !composing) throw new Error('Open a session, or /new. /permissions default <mode> sets new sessions.');
2293
+ if (task && isPeerRun(task.origin)) throw new Error(READ_ONLY_PEERS);
2294
+ const mode = args[0] ? checkMode(args[0], { allowFull: !(composing && composeWhen) }) : await pickMode({ schedule: composing && Boolean(composeWhen) });
2295
+ if (!mode || args[0] === 'full' && !await confirmFull()) return;
2296
+ if (composing) { composeMode = mode; if (composePlace.kind === 'here' && mode !== 'full') composePlace = { kind: 'worktree' }; setMessage(`${LABELS[mode]} for this session.`); return; }
2297
+ setMessage(setMode(task, mode)); return;
2298
+ }
2299
+ // /place [worktree|branch <name>|here]: where the new session works. With no word it asks.
2300
+ if (name === 'place') {
2301
+ if (!controller) throw new Error(NO_PROJECT);
2302
+ if (!composing()) throw new Error('A session keeps the place it started in. /new starts another, and /place says where that one runs.');
2303
+ const kind = args[0]?.toLowerCase(), mode = composeMode ?? defaultMode();
2304
+ if (kind && !['worktree', 'branch', 'here'].includes(kind) || args.length !== (kind === 'branch' ? 2 : kind ? 1 : 0)) throw new Error(`Use ${command.usage}.`);
2305
+ const place = kind ? checkPlace(kind === 'branch' ? { kind, branch: args[1] } : kind, { mode }) : await pickPlace({ mode });
2306
+ if (place) { composePlace = place; setMessage(`${placeText(place)} for this session.`); }
2307
+ return;
2308
+ }
2309
+ if (name === 'new') return newTaskForm();
2310
+ if (name === 'schedule') { await newTaskForm(); if (controller && view === 'chat') await setWhen(); return; }
2311
+ if (name === 'sessions') { showView('tasks'); return; }
2312
+ if (name === 'kanban') { showView('kanban'); return; }
2313
+ if (name === 'chats') { showView('chats'); return; }
2314
+ // /agent add <model> [what it does] [--name NAME] puts a model in the session with no bot
2315
+ // written first: it makes (or finds) a bot on that model that works when written to, and
2316
+ // adds it as /bots add does. The words after the model say what the agent is for.
2317
+ if (name === 'agent' || name === 'agents') {
2318
+ if (!controller) throw new Error(NO_PROJECT);
2319
+ const task = openTask();
2320
+ if (!task && !composing()) throw new Error('Open a session first.');
2321
+ if (!args.length) { setMessage(`In this session: ${[task ? modelOf(task) : nameOf(modelNow()), ...chatBotsNow().map(bot => `@${bot}`)].join(', ')}. /agent add <model> adds another.`); return; }
2322
+ if (args[0] === 'remove' && args.length === 2) return chatBot('remove', args[1].replace(/^@/, '').toLowerCase());
2323
+ if (args[0] !== 'add' || args.length < 2) throw new Error('Use /agent add <model> [what it does] [--name NAME], or /agent remove <name>.');
2324
+ controller.writable();
2325
+ const choice = findModel(args[1]), { role, name: called } = agentWords(String(command.text ?? '').replace(/^add\s+\S+/, ''));
2326
+ try { return addAgent(choice, { name: called, role, label: nameOf(choice), join: bot => chatBot('add', bot, { again: true }) }); }
2327
+ finally { botCache.at = 0; }
2328
+ }
2329
+ if (name === 'bots') {
2330
+ // With no words it also says which bots of the open session run by themselves here, and when next.
2331
+ if (!args.length) { const task = openTask(), lines = task ? botScheduleLines(controller.repo, task) : []; showView('bots'); if (lines.length) setMessage(lines.join(' ')); return; }
2332
+ const words = args.map(word => word.replace(/^@/, '').toLowerCase());
2333
+ // A bot's schedule is turned off or on for the open session alone, and run tells it to work now.
2334
+ if (words[0] === 'schedule' && ['on', 'off'].includes(words[1]) && words.length === 3 || words[0] === 'run' && words.length === 2) {
2335
+ const task = openTask(), bot = words.at(-1);
2336
+ if (!task) throw new Error('Open a session first.');
2337
+ controller.writable();
2338
+ if (words[0] === 'run') { runBotNow(controller.repo, task.id, bot, new Date(), { start: conduct }); setMessage(`@${bot} runs now. Its reply comes in this session.`); return; }
2339
+ const now = setBotSchedule(controller.repo, task.id, bot, words[1] === 'on'); if (now.on) tickBots();
2340
+ setMessage(now.on ? `@${bot} runs here ${now.label}, next ${dateText(new Date(now.nextRunAt))}.` : `@${bot} no longer runs by itself in this session. Its other sessions are as they were. /bots schedule on ${bot} turns it back on.`);
2341
+ return;
2342
+ }
2343
+ // A bot's colour, wherever its name shows. With no colour a new one is picked at random.
2344
+ if (words[0] === 'color') {
2345
+ if (words.length < 2 || words.length > 3) throw new Error(`Use /bots color NAME COLOR. COLOR is random or one of: ${BOT_COLORS.join(', ')}.`);
2346
+ const bot = needBot(words[1]), random = words[2] === undefined || words[2] === 'random';
2347
+ const saved = saveBot({ ...bot, color: random ? pickBotColor(listBots().filter(row => row.name !== bot.name), { not: botColor(bot) }) : words[2] });
2348
+ botCache = { at: 0, rows: [] }; setMessage(`@${saved.name} is ${saved.color} now.`); return;
2349
+ }
2350
+ const bot = words[1] ?? '';
2351
+ if (!['add', 'remove'].includes(args[0]) || !bot || args.length > 2) throw new Error('Use /bots, /bots add NAME, /bots remove NAME, /bots schedule on NAME, /bots schedule off NAME, /bots run NAME or /bots color NAME COLOR.');
2352
+ return chatBot(args[0], bot);
2353
+ }
2354
+ if (name === 'projects') { showView('projects'); return; }
2355
+ if (name === 'usage') { showView('usage'); return; }
2356
+ if (['peers', 'invite', 'join', 'share', 'peer'].includes(name)) return peerSlash(command);
2357
+ if (name === 'slots') {
2358
+ if (!args.length) { setMessage(`${conductorSettings().maxAgents} sessions run at once. Use /slots <1-16> to change it.`); return; }
2359
+ if (args.length > 1 || !/^[0-9]+$/.test(args[0])) throw new Error('Use /slots <1-16>.');
2360
+ setMessage(`${saveConductorSettings({ maxAgents: Number(args[0]) }).maxAgents} sessions run at once. Running work is not interrupted.`); return;
2361
+ }
2362
+ if (!controller) throw new Error(NO_PROJECT);
2363
+ if (name === 'changes') return showChanges();
2364
+ if (name === 'chat') return showChat();
2365
+ if (name === 'details') { const task = view === 'chat' && controller.snapshot().tasks.find(row => row.id === selectedId); if (!task) throw new Error('Open a session first.'); return showDetails(repo, task); }
2366
+ if (name === 'publish') return publishCurrent();
2367
+ if (name === 'stop') return stopCurrent(args[0]);
2368
+ if (name === 'archive') {
2369
+ const task = view === 'chat' && selectedId && controller.snapshot().tasks.find(row => row.id === selectedId);
2370
+ if (!task) throw new Error('Open a session to archive it. In Sessions, c archives the selected one.');
2371
+ return closeTask(controller.repo, task, () => { showView(returnTo); focusContent(); });
2372
+ }
2373
+ if (name === 'archived') { toggleArchived(); return; }
2374
+ if (name === 'rename') {
2375
+ const task = view === 'chat' && selectedId && controller.snapshot().tasks.find(row => row.id === selectedId);
2376
+ if (!task) throw new Error('Open a session to rename it. In Sessions, e renames the selected one.');
2377
+ return renameTask(controller.repo, task, args.length ? args.join(' ') : null);
2378
+ }
2379
+ if (name === 'mcp') {
2380
+ const { all, on } = controller.servers();
2381
+ if (!args.length) return prompt('MCP servers', [all.length ? all.map(server => `${on.includes(server) ? '● on ' : '○ off'} ${server}`).join('\n') : 'No MCP servers in Claude Code. Add one with claude mcp add.', '', 'Your Claude Code servers (claude mcp list). Sessions use them all.', '/mcp off NAME turns one off for this project. Teammates\' asks never get them.'].join('\n'));
2382
+ if (args.length !== 2 || !['on', 'off'].includes(args[0])) throw new Error('Use /mcp, /mcp on NAME or /mcp off NAME.');
2383
+ setMessage(controller.setServer(args[1], args[0] === 'on')); return;
2384
+ }
2385
+ if (name === 'setup') { if (busyHere()) throw new Error('Wait for running responses before advanced setup.'); return controller.snapshot().initialized ? reviewSetup() : prepareProject(); }
2386
+ }
2387
+ function positionModal() {
2388
+ modal.width = Math.min(88, screen.width - 4); modal.height = Math.min(screen.height - 4, modal.preferredHeight ?? screen.height - 4);
2389
+ modal.left = Math.floor((screen.width - modal.width) / 2); modal.top = Math.floor((screen.height - modal.height) / 2);
2390
+ }
2391
+ // keys: single letters the body names (y accepts, f forwards…). While the field is
2392
+ // empty, or still holds the preselected letter Enter would send, pressing one closes the
2393
+ // dialog with that letter. trusted: the body is a trusted teammate's text, shown as sent.
2394
+ // suggest: a function from the typed text to the options that match it, as rows { name,
2395
+ // note }. They are listed under the body. Tab takes the marked one, and an arrow moves the
2396
+ // mark and takes that one. A row with a value puts that in the field when it is taken, not
2397
+ // its name: a field of several names keeps the ones before. check: a function from the
2398
+ // field's text to the reason it cannot be sent, or null. Enter on such a text shows the
2399
+ // reason and keeps the dialog open. warn: the same for a text that may be sent anyway, so a
2400
+ // second Enter on it submits it.
2401
+ function prompt(title, body, value = '', { keys = [], preselect = '', trusted = false, suggest = null, check = null, warn = null } = {}) {
2402
+ if (modal) throw new Error('Close the current dialog first.');
2403
+ return new Promise(resolve => {
2404
+ openDialog();
2405
+ modal = dialogBox(title);
2406
+ const guided = Boolean(suggest || check || warn), all = suggest?.('') ?? [];
2407
+ modal.preferredHeight = Math.max(12, String(body).split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / Math.min(80, screen.width - 12))), 0) + 7 + (guided ? all.length + 3 : 0)); positionModal();
2408
+ const text = blessed.box({ parent: modal, top: 1, left: 1, width: '100%-2', height: '100%-5', scrollable: true, keys: true, tags: guided, content: guided ? '' : blockText(body, { trusted }), scrollbar: { ch: '|' } });
2409
+ const field = blessed.textbox({ parent: modal, bottom: 1, left: 1, height: 3, width: '100%-2', border: 'line', label: suggest ? ' Enter submits / Tab completes / ↑↓ choose / Esc cancels ' : ' Enter submits / Esc cancels / PageUp reads ', keys: true, mouse: true, inputOnFocus: false, value, tags: false, style: { fg: shade('text'), bg: shade('raised'), border: { fg: accent } } });
2410
+ bindInput(field, screen); editable(field);
2411
+ // The options for the text typed so far, under the body, with a mark on the one Tab
2412
+ // takes. Rows that do not fit give way to a +N more line. The line from check or warn
2413
+ // goes last, once Enter was refused or nothing matches. A field that opens with a
2414
+ // value lists every option until something is typed.
2415
+ let typed = '', options = [], mark = Math.max(0, all.findIndex(row => row.name === value)), refused = null;
2416
+ const column = Math.max(0, ...all.map(row => row.name.length)) + 2;
2417
+ const list = () => {
2418
+ const wide = Math.max(8, text.width - 1), lines = wrapText(blockText(body, { trusted }), wide), stop = check?.(field.value) ?? null, doubt = stop ?? warn?.(field.value) ?? null;
2419
+ options = suggest?.(typed) ?? []; mark = Math.max(0, Math.min(mark, options.length - 1));
2420
+ const said = !doubt ? null : refused === field.value ? stop ?? `${doubt} Enter again submits it as typed.` : typed && !options.length ? doubt : null, notes = said ? ['', ...wrapText(said, wide)] : [];
2421
+ const room = Math.max(2, text.height - lines.length - 1 - notes.length), size = options.length > room ? room - 1 : options.length, start = Math.min(Math.max(0, mark - size + 1), options.length - size);
2422
+ const rows = options.slice(start, start + size).map((row, index) => `${ink(start + index === mark ? '>' : ' ', 'orange', colour, true)} ${ink(row.name.padEnd(column), null, colour, start + index === mark)}${ink(fitWords(row.note, wide - column - 2), 'muted', colour)}`);
2423
+ text.setContent([...lines.map(line => displayText(line, { trusted })), ...(rows.length ? ['', ...rows] : []), ...(size < options.length ? [ink(` +${options.length - size} more`, 'muted', colour)] : []), ...notes.map(line => ink(line, 'orange', colour))].join('\n'));
2424
+ };
2425
+ if (guided) list();
2426
+ // One line: Enter submits, as in blessed's textbox.
2427
+ const edit = field._listener;
2428
+ field._listener = function(ch, key = {}) {
2429
+ if (key.name === 'enter' && (check?.(this.value) || warn?.(this.value) && refused !== this.value)) { refused = this.value; list(); screen.render(); return; }
2430
+ if (key.name === 'enter') return this._done?.(null, this.value);
2431
+ // Tab takes the marked option, then the one after it. An arrow moves the mark and takes that one.
2432
+ if (options.length && ['tab', 'up', 'down'].includes(key.name)) {
2433
+ const taken = row => row.value ?? row.name;
2434
+ if (key.name !== 'tab' || this.value === taken(options[mark])) mark = (mark + (key.name === 'up' || key.shift ? -1 : 1) + options.length) % options.length;
2435
+ this.setValue(taken(options[mark])); list(); screen.render(); return;
2436
+ }
2437
+ if ((!this.value || this.value === preselect) && keys.includes(ch?.toLowerCase()) && !key.ctrl && !key.meta) return this._done?.(null, ch.toLowerCase());
2438
+ const before = this.value, result = edit.call(this, ch, key);
2439
+ if (guided && this.value !== before) { typed = this.value; mark = 0; refused = null; list(); screen.render(); }
2440
+ return result;
2441
+ };
2442
+ let finished = false;
2443
+ const finish = answer => { if (finished) return; finished = true; closeDialog(resolve, answer); };
2444
+ modal.dismiss = () => finish(null); modal.keep = () => Boolean(field.value) && field.value !== value;
2445
+ field.on('submit', answer => finish(answer)); field.on('cancel', () => finish(null));
2446
+ field.on('focus', () => field.readInput());
2447
+ field.key('pageup', () => { text.scroll(-5); screen.render(); }); field.key('pagedown', () => { text.scroll(5); screen.render(); });
2448
+ field.focus(); minimumSize(); screen.render();
2449
+ });
2450
+ }
2451
+ // Dialogs hand focus back to where the first of them was opened (a settings dialog opens
2452
+ // an editor, then comes back), else to the message box, unless something has taken focus
2453
+ // since the last one closed.
2454
+ let dialogOrigin = null;
2455
+ function openDialog() { if (ask._reading) ask.cancel(); dialogOrigin ??= screen.focused; }
2456
+ function closeDialog(resolve, answer) {
2457
+ modal.destroy(); modal = null; const settled = screen.focused; draw(); resolve(answer);
2458
+ setImmediate(() => {
2459
+ if (modal || closing) return;
2460
+ const before = dialogOrigin; dialogOrigin = null;
2461
+ if (screen.focused !== settled) return;
2462
+ if (before && before !== ask && !before.destroyed && !before.detached && before.visible) { before.focus(); draw(); } else focusInput();
2463
+ });
2464
+ }
2465
+ // A dialog takes the clicks on its own border and text, so none falls through to the view
2466
+ // behind it, and such a click never takes focus from its field or list.
2467
+ const dialogBox = title => box({ label: ` ${title} `, clickable: true, autoFocus: false });
2468
+ // A dialog is modal for the mouse. A press outside it reaches nothing behind it: it closes
2469
+ // a dialog with nothing typed in it, as Esc does, and does nothing to one whose text would
2470
+ // be lost (keep). The rest of that press goes nowhere either, since blessed turns a release
2471
+ // into a click on whatever is under it, and nor does the wheel outside the dialog.
2472
+ // held is where the press in progress began: a dialog, false outside one, null with none
2473
+ // open. This runs before the selection and blessed, which both pass over an unknown action.
2474
+ let held = null, pressed = { at: 0, x: -1, y: -1 };
2475
+ screen.program.prependListener('mouse', data => {
2476
+ const pos = modal?.lpos, outside = Boolean(modal) && !(pos && data.x >= pos.xi && data.x < pos.xl && data.y >= pos.yi && data.y < pos.yl);
2477
+ if (data.action === 'mousedown' && !(buttonCode(data) & 32)) {
2478
+ const now = Date.now(), again = now - pressed.at < 500 && pressed.x === data.x && pressed.y === data.y;
2479
+ pressed = { at: now, x: data.x, y: data.y }; held = outside ? false : modal;
2480
+ // A double click on the row that opens a dialog: its second press does not close it again.
2481
+ if (outside && !again && !modal.keep?.()) modal.dismiss();
2482
+ }
2483
+ // A drag or a release belongs to the dialog its press began in.
2484
+ if (data.action === 'wheelup' || data.action === 'wheeldown' ? !outside : held !== false && (!modal || held === modal)) return;
2485
+ if (data.action === 'mouseup') { held = null; screen.mouseDown = null; }
2486
+ data.action = 'mouseoutside';
2487
+ });
2488
+ // Keys belong to the open dialog. Should focus be outside it, a key first puts it back on
2489
+ // the dialog's field or list (reader, the one that last had it), so Esc closes the dialog
2490
+ // and nothing behind it acts on the key. blessed reads the focused widget after this runs.
2491
+ screen.on('element focus', el => { if (modal && el.hasAncestor(modal)) modal.reader = el; });
2492
+ screen.program.prependListener('keypress', () => {
2493
+ if (!modal?.reader || closing || screen.focused?.hasAncestor(modal)) return;
2494
+ modal.reader.focus(); draw();
2495
+ });
2496
+ // A list in a dialog: Enter picks a row ({ index }), a named letter picks it with that letter
2497
+ // ({ index, key }), Esc closes it (null). Rows are tagged text; selected is the row to start on.
2498
+ // notes are plain lines shown muted under the rows, wrapped one column short of their box
2499
+ // so that blessed never wraps a full line again.
2500
+ // shown: the notes are a file's own text, shown as it is, with nothing taken out.
2501
+ function listDialog(title, rows, { hint = 'enter choose · esc close', keys = [], selected = 0, notes = [], shown = false } = {}) {
2502
+ if (modal) throw new Error('Close the current dialog first.');
2503
+ return new Promise(resolve => {
2504
+ openDialog();
2505
+ modal = dialogBox(title);
2506
+ const said = notes.flatMap(note => wrapText(note, Math.min(88, screen.width - 4) - 5)), under = said.length ? said.length + 1 : 0;
2507
+ modal.preferredHeight = Math.max(8, rows.length + 4 + under); positionModal();
2508
+ if (under) blessed.text({ parent: modal, bottom: 3, left: 2, width: '100%-4', height: said.length, tags: true, content: said.map(line => ink(line || ' ', shown ? 'text' : 'muted', colour, false, { trusted: shown })).join('\n'), style: { fg: muted, bg: shade('panel') } });
2509
+ const list = blessed.list({ parent: modal, top: 1, left: 1, width: '100%-2', height: `100%-${4 + under}`, keys: true, mouse: true, vi: false, tags: true, invertSelected: false, style: { fg: shade('text'), bg: shade('panel'), selected: { ...selection } }, scrollbar: { ch: '┃', style: { fg: shade('dim') } } });
2510
+ list.setItems(rows); list.select(Math.max(0, Math.min(selected, rows.length - 1)));
2511
+ blessed.text({ parent: modal, bottom: 1, left: 2, width: '100%-4', height: 1, tags: true, content: ink(hint, 'muted', colour), style: { fg: muted, bg: shade('panel') } });
2512
+ let finished = false;
2513
+ const finish = answer => { if (finished) return; finished = true; closeDialog(resolve, answer); };
2514
+ modal.dismiss = () => finish(null);
2515
+ list.on('select', (_, index) => finish({ index: index ?? list.selected ?? 0 }));
2516
+ list.on('cancel', () => finish(null));
2517
+ for (const key of keys) list.key(withShift([key]), () => finish({ index: list.selected ?? 0, key }));
2518
+ list.on('select item', () => screen.render());
2519
+ list.focus(); minimumSize(); screen.render();
2520
+ });
2521
+ }
2522
+ // A text editor in a dialog: Enter adds a line, a paste goes in whole, Ctrl-S saves (the
2523
+ // text), Esc cancels (null). note: a line shown above the keys, such as a refused save.
2524
+ function editText(title, value = '', { note = '' } = {}) {
2525
+ if (modal) throw new Error('Close the current dialog first.');
2526
+ return new Promise(resolve => {
2527
+ openDialog();
2528
+ modal = dialogBox(title);
2529
+ modal.preferredHeight = screen.height - 4; positionModal();
2530
+ const field = blessed.textarea({ parent: modal, top: 1, left: 1, width: '100%-2', height: '100%-5', inputOnFocus: false, keys: true, mouse: true, tags: false, value, style: { fg: shade('text'), bg: shade('raised') }, scrollbar: { ch: '┃', style: { fg: shade('dim') } } });
2531
+ bindInput(field, screen); editable(field); field.setValue(value);
2532
+ blessed.text({ parent: modal, bottom: 1, left: 2, width: '100%-4', height: 2, tags: true, content: [note && ink(note, 'orange', colour), ink('Enter adds a line · Ctrl-S saves · Esc cancels', 'muted', colour)].filter(Boolean).join('\n'), style: { fg: muted, bg: shade('panel') } });
2533
+ const edit = field._listener;
2534
+ field._listener = function(ch, key = {}) {
2535
+ if (key.ctrl && key.name === 's') return this._done?.(null, this.value);
2536
+ if (key.name === 'enter' && key.sequence !== '\n' && !key.ctrl && !key.meta) return edit.call(this, '\n', { ...key, sequence: '\n' });
2537
+ return edit.call(this, ch, key);
2538
+ };
2539
+ let finished = false;
2540
+ const finish = answer => { if (finished) return; finished = true; closeDialog(resolve, answer); };
2541
+ modal.dismiss = () => finish(null); modal.keep = () => true;
2542
+ field.on('submit', answer => finish(answer)); field.on('cancel', () => finish(null));
2543
+ field.on('focus', () => field.readInput());
2544
+ field.focus(); minimumSize(); screen.render();
2545
+ });
2546
+ }
2547
+ async function yes(title, body, question = 'Confirm? [y/N]') { return /^y(?:es)?$/i.test((await prompt(title, `${body}\n\n${question}`))?.trim() ?? ''); }
2548
+ async function reviewSetup() {
2549
+ const plan = controller.setupCommitPlan();
2550
+ if (!plan.files.length) { setMessage('The project setup is already committed.'); return true; }
2551
+ if (await yes('Review project setup', `Commit these setup files on ${plan.branch}?\n\n${plan.diff}\n\nThis makes a local commit. Nothing is pushed. Project commit hooks are skipped.`)) { setMessage(controller.commitSetup(plan)); return true; }
2552
+ setMessage('Advanced setup remains uncommitted. Chat still works without committing it.'); return false;
2553
+ }
2554
+ async function prepareProject() {
2555
+ const history = controller.historyPlan();
2556
+ if (history) {
2557
+ if (!await yes('Start project history', `${history.root}\n\n${history.message}\n\nFiles in the initial checkpoint:\n${history.files.map(file => file.path).join('\n') || '(empty checkpoint)'}`)) throw new Error('Project history was not created. Your request is saved.');
2558
+ repo = controller.createHistory(history); claim.attach(repo);
2559
+ }
2560
+ if (!controller.snapshot().initialized) {
2561
+ if (!controller.draftConfig.checks.length) {
2562
+ const command = await prompt('Project checks', 'How should this project be checked? Examples: npm test, node --test, python3 -m pytest, go test ./...\nEnter words separated by spaces, or a JSON argument array for paths with spaces. These checks run with network access blocked.', 'node --test');
2563
+ if (!command?.trim()) throw new Error('Project checks are still needed. Your request is saved.');
2564
+ controller.setChecks(command.trim().startsWith('[') ? JSON.parse(command) : command.trim().split(/\s+/));
2565
+ }
2566
+ const plan = controller.setupPlan();
2567
+ const body = ['Runs on:', ...Object.entries(plan.config.roles).map(([name, role]) => `${name === 'lead' ? 'model' : name}: ${role.vendor}, ${role.access}, network ${role.network}`), '\nChecks:', ...plan.config.checks.map(check => check.command.join(' ')), '\nWrites:', ...plan.writes, '\n' + plan.message].join('\n');
2568
+ if (!await yes('Set up project', body)) throw new Error('Setup was cancelled. Your request is saved.');
2569
+ setMessage(controller.setup(plan).message);
2570
+ if (!await reviewSetup()) throw new Error('Review and commit the project settings before starting. Your request is saved.');
2571
+ usageEnabled = true; controller.refreshUsage().then(draw);
2572
+ }
2573
+ }
2574
+ // Where a ! command runs and the session it joins: the open session's folder, else the
2575
+ // open project, else where ELU Code started. A teammate's question never takes one.
2576
+ function shellPlace() {
2577
+ const task = controller && view === 'chat' && selectedId && !selectedRequest ? (snapshot?.tasks ?? []).find(row => row.id === selectedId) : null;
2578
+ let cwd = controller?.repo.root ?? startDirectory;
2579
+ if (task?.worktree) { try { if (fs.statSync(task.worktree).isDirectory()) cwd = task.worktree; } catch { /* That folder is gone; the project folder is used. */ } }
2580
+ return { cwd, session: task && !task.origin && !claim.readOnly ? { repo: controller.repo, id: task.id } : null };
2581
+ }
2582
+ // One ! command at a time. In an open session it joins the conversation, so the lead sees
2583
+ // it with the next message; anywhere else its output opens when it ends.
2584
+ function shellCommand(command) {
2585
+ if (!command) throw new Error('Type a command after !, such as !git status.');
2586
+ if (command.length > 4000) throw new Error('Keep the command under 4,000 characters.');
2587
+ if (shellRun) throw new Error(`Still running: ${clip(shellRun.command, 40)}. /stop ends it.`);
2588
+ const { cwd, session } = shellPlace(), at = new Date().toISOString();
2589
+ const run = runShell(command, { cwd, onOutput: () => { if (!drawing && !closing) scheduleDraw(); } });
2590
+ shellRun = { command, cwd, at, run, session };
2591
+ setMessage(session ? '' : `Running ${clip(command, 50)} · /stop ends it`);
2592
+ run.done.then(async result => {
2593
+ shellRun = null;
2594
+ if (closing) return;
2595
+ const row = { at, command, cwd, ...result }, text = shellText(row);
2596
+ if (session) { try { recordShell(session.repo, session.id, row); } catch (error) { setMessage(error.message); } draw(); return; }
2597
+ setMessage(modal ? `Finished: ${clip(text.replace(/\n/g, ' · '), 70)}` : ''); draw();
2598
+ if (!modal) await prompt(clip(`$ ${command}`, 60), text.includes('\n') ? text : `${text}\n(no output)`);
2599
+ }).catch(error => { if (!closing) { setMessage(error.message); draw(); } });
2600
+ }
2601
+ // Up and Down in the message box walk through what the owner sent, as in a shell: the open
2602
+ // session's own messages, then everything sent since the app opened. It is kept in memory only.
2603
+ const sentNow = [];
2604
+ let recall = null;
2605
+ // The turn the last /stop was for, so a second one is told from the first.
2606
+ let stopAsked = null;
2607
+ const sentBefore = () => {
2608
+ const task = openTask(), own = task ? [...(task.origin ? [] : [task.goal]), ...(task.messages ?? []).filter(row => !row.bot && !row.from && !row.scheduled).map(row => row.to ? `${row.to} ${row.text}` : row.text)] : [];
2609
+ // A session's first message is its goal and is also its first saved message: one of them is enough.
2610
+ return [...own.filter(text => typeof text === 'string' && text.trim()).map(text => text.trim()).filter((text, at, all) => text !== all[at - 1] && !sentNow.includes(text)), ...sentNow];
2611
+ };
2612
+ // The message box sends to whatever the view shows: a new task, the open task, or a teammate.
2613
+ // The new session form with a bot that has a schedule in it, and nothing typed: the session
2614
+ // starts with that bot's first turn, and no message goes to the model. Other scheduled bots
2615
+ // of the form start by themselves once the session is there. false when this is not that form.
2616
+ function startWithBot() {
2617
+ if (!controller || !composing() || composeWhen) return false;
2618
+ const starter = composeBots.find(name => botScheduleIn(controller.repo, null, name)?.label);
2619
+ if (!starter) return false;
2620
+ let request;
2621
+ try {
2622
+ controller.writable();
2623
+ request = enqueue(controller.repo.root, 'Start working.', { priority: 0, permissions: composeMode ?? defaultMode(), place: composePlace, folders: composeFolders, bots: composeBots, ...(composeModel ? { model: composeModel } : {}), bot: starter });
2624
+ } catch (error) { setMessage(error.message); focusInput(); return true; }
2625
+ recall = null; palette.hide(); chatShown = null; message = ''; drafts.delete(draftKey());
2626
+ following = { root: controller.repo.root, id: request.id, bot: starter };
2627
+ sessionView = 'chat'; selectedId = null; selectedRequest = request;
2628
+ ask.clearValue(); usageEnabled = true; controller.refreshUsage().then(draw);
2629
+ conduct(); resize(); focusInput();
2630
+ return true;
2631
+ }
2632
+ async function submit(text) {
2633
+ // An empty Enter keeps the box reading: a box left focused but not reading drops keys.
2634
+ // In the new session form it starts the session when a bot that has a schedule is in it.
2635
+ if (!text.trim()) { if (!startWithBot()) focusInput(); return; }
2636
+ openStarted();
2637
+ recall = null;
2638
+ if (sentNow.at(-1) !== text.trim()) { sentNow.push(text.trim()); if (sentNow.length > 200) sentNow.shift(); }
2639
+ palette.hide();
2640
+ // What the owner sends shows at the end of the chat, wherever he had scrolled to.
2641
+ chatShown = null;
2642
+ // ! runs the rest in a shell, as in a terminal. It is never sent to the model or a teammate.
2643
+ if (text.trimStart().startsWith('!')) {
2644
+ try { shellCommand(text.trimStart().slice(1).trim()); ask.clearValue(); sessionView = 'chat'; }
2645
+ catch (error) { ask.setValue(text); setMessage(error.message); }
2646
+ finally { focusInput(); draw(); }
2647
+ return;
2648
+ }
2649
+ if (text.trimStart().startsWith('/')) {
2650
+ try { const command = parseCommand(text); ask.clearValue(); await dispatch(command); }
2651
+ catch (error) { ask.setValue(text); paletteDismissed = text; setMessage(error.message); }
2652
+ finally { if (!modal) focusInput(); draw(); }
2653
+ return;
2654
+ }
2655
+ // A file that changed while the project was open is asked about before the message goes.
2656
+ if (controller && !claim.readOnly) await catchAction(() => reviewProjectFiles(controller.repo.root));
2657
+ let mention = null, toModel = false;
2658
+ // What goes back in the box when the message cannot be sent: the text as it was typed.
2659
+ const typed = () => toModel ? `@model ${text}` : text;
2660
+ try {
2661
+ if (view === 'chats') {
2662
+ // @name writes to that teammate whatever is open; otherwise the open channel or person gets it.
2663
+ let words = text.trim().split(/\s+/);
2664
+ // @name of one of my own bots, when no teammate has that name: its chat opens and takes the rest.
2665
+ const mine = /^@[a-z0-9-]+$/i.test(words[0]) && !teammateNamed(words[0]) ? listBots().find(bot => bot.name === words[0].slice(1).toLowerCase()) ?? null : null;
2666
+ if (mine) {
2667
+ chatOpen = { type: 'bot', target: botTarget(null, mine.name), name: mine.name, peer: null, owner: null, box: null, about: mine.about, online: true };
2668
+ chatJump = chatTarget = chatOpen.target;
2669
+ text = text.trim().slice(words[0].length).trim(); words = text.split(/\s+/);
2670
+ if (!text) { ask.clearValue(); setMessage(`Write your message to @${mine.name}.`); focusInput(); return; }
2671
+ }
2672
+ if (!mine && words[0].startsWith('@') || !chatOpen) await peerSlash({ name: 'ask', args: words, text: text.trim() });
2673
+ else if (text.trim().length > 16000) throw new Error('Keep it under 16,000 characters.');
2674
+ else if (chatOpen.type === 'member') {
2675
+ // Every word is the message: only @name with a handle picks a project.
2676
+ const who = peerName(chatOpen.name, chatOpen.key);
2677
+ sentLine(await peerCommand({ type: 'ask', to: chatOpen.key, project: null, text: text.trim() }), who, `Sent to ${who}. It runs after they accept it.`);
2678
+ } else if (chatOpen.type === 'bot' && chatOpen.peer) {
2679
+ // A teammate's bot: the question goes to their machine and names the bot.
2680
+ const owner = peerName(chatOpen.owner, chatOpen.peer);
2681
+ sentLine(await peerCommand({ type: 'ask', to: chatOpen.peer, bot: chatOpen.name, project: null, text: text.trim() }), owner, `Sent to @${chatOpen.name} on ${botHome(chatOpen)}. It runs after ${owner} accepts it.`);
2682
+ } else if (chatOpen.type === 'bot') {
2683
+ // My own bot has one chat in the active project: this message continues it, and the
2684
+ // first one starts it. A bot that has a schedule then also runs there by itself.
2685
+ if (!controller) throw new Error(NO_PROJECT);
2686
+ controller.writable();
2687
+ const own = botChatSession(controller.repo, chatOpen.name);
2688
+ if (own?.starting) throw new Error(`@${chatOpen.name} is still starting its session. Send this again in a moment.`);
2689
+ if (own) enqueue(controller.repo.root, text.trim(), { priority: 0, continuationTaskId: own.id, bot: chatOpen.name });
2690
+ else enqueue(controller.repo.root, text.trim(), { priority: 0, permissions: defaultMode(), place: { kind: 'worktree' }, folders: [], bot: chatOpen.name, botChat: true });
2691
+ conduct();
2692
+ setMessage(own ? `@${chatOpen.name} answers in its session. It is in Sessions.` : `@${chatOpen.name} answers in a new session. It is in Sessions.`);
2693
+ } else {
2694
+ const result = await peerCommand({ type: 'teamAsk', project: null, text: text.trim(), team: chatOpen.team, channel: chatOpen.channel });
2695
+ setMessage(withCount(result, `Sent to #${chatOpen.name}. The first teammate to take it answers.`));
2696
+ }
2697
+ ask.clearValue(); focusInput(); return;
2698
+ }
2699
+ if (view === 'kanban') {
2700
+ if (!controller && !await createHere()) { ask.setValue(text); focusInput(); return; }
2701
+ addCard(controller.repo, text); ask.clearValue(); setMessage('Added to To do. Shift+→ on it starts it.'); focusInput(); return;
2702
+ }
2703
+ // @model writes to the session's model, whoever the owner wrote to last. Alone it sends nothing.
2704
+ if (TO_MODEL.test(text.trimStart())) {
2705
+ text = text.trimStart().replace(TO_MODEL, '').trim(); toModel = true;
2706
+ if (!text) { ask.setValue('@model '); setMessage('Write your message after @model.'); focusInput(); return; }
2707
+ }
2708
+ // @name first sends the message to that saved bot; any other @word goes where a message with no name goes.
2709
+ if (!toModel && text.trimStart().startsWith('@')) mention = botMessage(text, listBots());
2710
+ // A teammate's @name asks them. A bot of that name comes first.
2711
+ const first = text.trim().split(/\s+/)[0], person = !mention && !toModel && text.trimStart().startsWith('@') ? teammateNamed(first) : null;
2712
+ if (person) {
2713
+ const question = text.trim().slice(first.length).trim();
2714
+ // The name alone adds nobody: the box keeps it, ready for the question.
2715
+ if (!question) { ask.setValue(`@${person.name} `); setMessage(`Write your question after @${person.name}.`); focusInput(); return; }
2716
+ await peerSlash({ name: 'ask', args: [`@${person.name}`, ...question.split(/\s+/)], text: `@${person.name} ${question}` });
2717
+ ask.clearValue(); focusInput(); return;
2718
+ }
2719
+ if (sessionBox()) {
2720
+ let root = goalTarget();
2721
+ if (!root) { if (!await createHere()) { ask.setValue(typed()); focusInput(); return; } root = controller?.repo.root; if (!root) return; }
2722
+ await newTaskForm(root); if (!controller) return;
2723
+ }
2724
+ controller.writable();
2725
+ // @name alone adds that bot to the open session, or to the new session form.
2726
+ if (mention && !mention.text) { chatBot('add', mention.bot.name, { again: true }); ask.clearValue(); focusInput(); return; }
2727
+ if (composeWhen && composing()) { if (mention) throw new Error(`A schedule is not written to a bot. Take @${mention.bot.name} off the start.`); return await scheduleTask(text); }
2728
+ } catch (error) { ask.setValue(typed()); setMessage(error.message); focusInput(); return; }
2729
+ // The session may have started while this was on its way.
2730
+ let pending = openStarted();
2731
+ // A message that waits for a turn of its session to end is not one to rewrite: this one
2732
+ // waits beside it in that session, and the two go together when the turn ends.
2733
+ if (selectedRequest && pending?.continuationTaskId && !pending.origin && ['queued', 'starting'].includes(pending.state)) { selectedId = pending.continuationTaskId; selectedRequest = null; }
2734
+ // The message that starts this session still waits for the conductor: this one goes with
2735
+ // it, in the order sent, as waiting messages go together. It never takes its place. One
2736
+ // written to someone else cannot go with it, so it stays in the box.
2737
+ if (selectedRequest && pending?.state === 'queued' && !pending.continuationTaskId && !pending.origin && !pending.scheduled && !pending.scheduleId) {
2738
+ // With no @name it goes to whoever the first was written to, and @model goes to the model.
2739
+ const to = mention ? mention.bot.name : toModel ? null : pending.bot ?? null;
2740
+ const same = to === (pending.bot ?? null), joined = same ? addToRequest(controller.repo.root, pending.id, mention ? mention.text : text) : null;
2741
+ if (joined) { message = ''; drafts.delete(draftKey()); selectedRequest = joined; ask.clearValue(); conduct(); resize(); focusInput(); return; }
2742
+ pending = openStarted();
2743
+ if (selectedRequest && pending?.state === 'queued') { ask.setValue(typed()); setMessage('This session has not started yet. Send this once it has.'); focusInput(); return; }
2744
+ }
2745
+ // While the conductor is still starting it there is no session to wait in yet, and the request is no longer one to rewrite.
2746
+ if (selectedRequest && pending?.state === 'starting') { ask.setValue(typed()); setMessage('This session is still starting. Send this again in a moment.'); focusInput(); return; }
2747
+ const root = controller.repo.root, continuation = selectedRequest ? selectedRequest.continuationTaskId ?? null : selectedId;
2748
+ message = '';
2749
+ drafts.delete(draftKey());
2750
+ // A new session carries the compose rows; a saved request keeps its own.
2751
+ const session = continuation ? {} : selectedRequest ? { permissions: selectedRequest.permissions, place: selectedRequest.place, folders: selectedRequest.folders, ...(selectedRequest.bots ? { bots: selectedRequest.bots } : {}), ...(selectedRequest.model ? { model: selectedRequest.model } : {}) } : { permissions: composeMode ?? defaultMode(), place: composePlace, folders: composeFolders, bots: composeBots, ...(composeModel ? { model: composeModel } : {}) };
2752
+ // A saved request keeps the bot it was written to, so one that now goes to the model is sent as a new request.
2753
+ const rewritten = Boolean(selectedRequest?.bot) && !mention;
2754
+ // In an open session a message with no @name goes to whoever the owner wrote to last: a bot they
2755
+ // wrote to stays the one they are talking to until they name another, or @model.
2756
+ let stays = null;
2757
+ if (!mention && !toModel && continuation && !selectedRequest) { try { stays = addressedBot(controller.snapshot().tasks.find(row => row.id === continuation), pendingRequests(root)); } catch { /* It goes to the model. */ } }
2758
+ const to = mention ? mention.bot.name : stays;
2759
+ let request;
2760
+ // The bot gets the message without its @name; the chat shows who it was written to.
2761
+ try { request = enqueue(root, mention ? mention.text : text, { id: rewritten ? undefined : selectedRequest?.id, continuationTaskId: continuation, priority: 0, ...session, ...(to ? { bot: to } : {}) }); if (rewritten) removeRequest(root, selectedRequest.id); }
2762
+ catch (error) { ask.setValue(typed()); setMessage(error.message); focusInput(); return; }
2763
+ following = { root, id: request.id, bot: to };
2764
+ // What was sent shows in the chat.
2765
+ sessionView = 'chat';
2766
+ selectedId = continuation; selectedRequest = continuation ? null : request;
2767
+ ask.clearValue(); usageEnabled = true; controller.refreshUsage().then(draw);
2768
+ conduct(); resize(); focusInput();
2769
+ }
2770
+ async function quit() {
2771
+ if (closing) return;
2772
+ if (modal?.dismiss) modal.dismiss(); else if (modal) modal.children.find(child => child.type === 'textbox')?.emit('cancel');
2773
+ closing = true; shellRun?.run.stop(); clearInterval(timer); clearInterval(usageTimer); clearInterval(scheduleTimer); clearInterval(changesTimer); clearTimeout(patchTimer); usageAbort.abort();
2774
+ let failure;
2775
+ try { await controller?.close(); } catch (error) { failure = error; }
2776
+ try { claim.release(); } catch (error) { failure ??= error; }
2777
+ screen.destroy(); if (failure) rejectDone(failure); else resolveDone();
2778
+ }
2779
+ ask.on('submit', text => catchAction(() => submit(text)));
2780
+ ask.on('focus', () => ask.readInput());
2781
+ // Esc leaves the message box for the view's main pane.
2782
+ ask.on('cancel', () => { palette.hide(); if (screen.focused === ask && !closing) focusContent(); else draw(); });
2783
+ // Tabs and buttons
2784
+ tabs.forEach((tab, index) => {
2785
+ tab.on('press', () => catchAction(() => { if (ask._reading) ask.cancel(); showView(tab.viewName); focusContent(); }));
2786
+ tab.key(['left', 'right'], (_, key) => { tabs[(index + (key.name === 'left' ? -1 : 1) + tabs.length) % tabs.length].focus(); draw(); });
2787
+ tab.key('down', () => { const next = subButtons()[0] ?? viewPanes()[0]; if (next) { next.focus(); draw(); } else focusInput(); });
2788
+ tab.on('focus', () => draw());
2789
+ });
2790
+ newBot.on('press', () => catchAction(() => botsAction('new')));
2791
+ newProject.on('press', () => catchAction(() => projectsAction('new')));
2792
+ back.on('press', () => catchAction(() => { if (ask._reading) ask.cancel(); showView(returnTo); focusContent(); }));
2793
+ newCard.on('press', () => catchAction(() => kanbanAction(0, 'new')));
2794
+ for (const widget of [newCard, newBot, newProject, back]) {
2795
+ widget.key(['left', 'right'], (_, key) => { const row = subButtons(), at = row.indexOf(widget); row[(at + (key.name === 'left' ? -1 : 1) + row.length) % row.length].focus(); draw(); });
2796
+ widget.key('up', () => { activeTab().focus(); draw(); });
2797
+ widget.key('down', () => focusContent());
2798
+ widget.on('focus', () => draw());
2799
+ }
2800
+ for (const widget of [...tabs, newCard, newBot, newProject, back]) widget.key('escape', () => focusInput());
2801
+ // Sessions: the cursor's row shows on the right as it moves. Enter or Right goes into it, to the message box.
2802
+ // A click on a row shows it and goes into it, to the message box, so what is typed next is
2803
+ // a message and not the list's letter keys. blessed has selected the row by then, and it
2804
+ // opens a row that was already selected by itself (select).
2805
+ let rowOpened = false;
2806
+ board.on('select', () => { rowOpened = true; queueMicrotask(() => { rowOpened = false; }); catchAction(() => boardAction('open')); });
2807
+ // A click on the line that counts the archived sessions shows them, and hides them again.
2808
+ board.on('element click', el => {
2809
+ if (modal || el === board) return;
2810
+ if (boardLines[board.items.indexOf(el)]?.archive) { toggleArchived(); scheduleDraw(); return; }
2811
+ if (!rowOpened) catchAction(() => boardAction('open'));
2812
+ });
2813
+ board.key('enter', () => { if (!modal) catchAction(() => boardAction('open')); });
2814
+ board.key('right', () => { if (!modal) focusInput(); });
2815
+ // n declines a peer row and starts a new task anywhere else; + always starts one.
2816
+ for (const [keys, action] of [[['a'], 'answer'], [['s', 'x'], 'stop'], [['r'], 'retry'], [['d'], 'diff'], [['c'], 'close'], [['e'], 'rename'], [['u'], 'unarchive'], [['p'], 'publish'], [['y'], 'accept'], [['f'], 'full'], [['w'], 'work'], [['n'], 'decline'], [['+'], 'new']]) board.key(withShift(keys), () => { if (!modal) catchAction(() => boardAction(action)); });
2817
+ board.key('escape', () => focusInput());
2818
+ board.on('focus', () => draw());
2819
+ // A click or the wheel on a heading lands on the nearest row the way it was going.
2820
+ board.on('select item', (_, index) => {
2821
+ if (drawing) return;
2822
+ if (!lineKey(boardLines[index])) {
2823
+ const down = boardLines.findIndex((line, place) => place > index && lineKey(line)), up = boardLines.findLastIndex((line, place) => place < index && lineKey(line));
2824
+ board.select(index < barAt && up >= 0 ? up : down >= 0 ? down : barAt); return;
2825
+ }
2826
+ pointBar(lineKey(boardLines[index])); scheduleDraw();
2827
+ });
2828
+ // Arrows skip the group headings. Past the first row are the tabs; past the last, the message box.
2829
+ const boardRowsAt = () => boardLines.map((line, index) => lineKey(line) ? index : -1).filter(index => index >= 0);
2830
+ function focusBoard() { const rows = boardRowsAt(); if (rows.length && !rows.includes(board.selected ?? 0)) board.select(rows[0]); board.focus(); }
2831
+ function boardStep(step) {
2832
+ const rows = boardRowsAt(), at = rows.indexOf(board.selected ?? 0), next = at < 0 ? 0 : at + step;
2833
+ if (!rows.length || next < 0 || next >= rows.length) return false;
2834
+ board.select(rows[next]); return true;
2835
+ }
2836
+ board.key(['up', 'down'], (_, key) => { if (modal) return; if (!boardStep(key.name === 'up' ? -1 : 1)) { if (key.name === 'up') activeTab().focus(); else focusInput(); } draw(); });
2837
+ board.key(['pageup', 'pagedown', 'home', 'end'], (_, key) => {
2838
+ if (modal) return;
2839
+ const rows = boardRowsAt(), at = Math.max(0, rows.indexOf(board.selected ?? 0)), page = Math.max(1, board.height - 2);
2840
+ const next = key.name === 'home' ? 0 : key.name === 'end' ? rows.length - 1 : Math.max(0, Math.min(rows.length - 1, at + (key.name === 'pageup' ? -page : page)));
2841
+ if (rows.length) board.select(rows[next]); draw();
2842
+ });
2843
+ // New task form
2844
+ composeList.on('select', () => catchAction(() => composeAction(composeRows[composeList.selected ?? 0])));
2845
+ composeList.key(['x', 'delete', 'backspace'], () => { if (!modal) catchAction(() => composeAction(composeRows[composeList.selected ?? 0], 'remove')); });
2846
+ // Esc or Left leaves the right side for the list. A session opened from Kanban goes back to the board.
2847
+ const leaveSession = () => { if (modal) return; if (!listed()) showView(returnTo); focusContent(); };
2848
+ composeList.key('escape', leaveSession);
2849
+ for (const pane of [composeList, activity]) pane.key('left', () => { if (listed()) leaveSession(); });
2850
+ composeList.on('select item', () => { if (!drawing) scheduleDraw(); });
2851
+ // Chats
2852
+ teamLists.forEach((list, index) => {
2853
+ list.on('select', () => catchAction(() => teamAction(index, 'open')));
2854
+ for (const [keys, action] of [[['y'], 'accept'], [['n'], 'decline'], [['a'], 'ask'], [['c'], 'continue'], [['t'], 'trust'], [['m'], 'members'], [['x'], 'leave']]) list.key(withShift(keys), () => { if (!modal) catchAction(() => teamAction(index, action)); });
2855
+ list.key('escape', () => focusInput());
2856
+ list.on('focus', () => { lastPane.chats = list; draw(); });
2857
+ list.on('select item', () => { if (!drawing) scheduleDraw(); });
2858
+ });
2859
+ teamLists[0].key('right', () => { if (!modal && screen.width >= 100) { teamLists[1].focus(); draw(); } });
2860
+ teamLists[1].key('left', () => { if (!modal && screen.width >= 100) { teamLists[0].focus(); draw(); } });
2861
+ activity.key('escape', leaveSession);
2862
+ // A session's two views share the right pane: its chat, and what it changed. One answer
2863
+ // from Git is kept per session, so coming back to a session shows it at once.
2864
+ let sessionView = 'chat', changesFrom = null, patchTimer = null, patchAsked = null, changesShown = null, built = { rows: [] };
2865
+ const patches = new Map();
2866
+ // The session whose changes are on screen, as root and id, or null.
2867
+ const patchKey = () => sessionView === 'changes' && view === 'chat' && controller && selectedId && snapshot?.tasks?.some(row => row.id === selectedId) ? `${controller.repo.root}\0${selectedId}` : null;
2868
+ const unfolded = (entry, file) => entry.fold.get(file.path) ?? entry.wide ?? true;
2869
+ // The rows for one width, built again only when the changes, the width or a fold moves.
2870
+ function patchRows(entry, width) {
2871
+ if (built.entry !== entry || built.changes !== entry.changes || built.width !== width || built.version !== entry.version) {
2872
+ const open = new Set(entry.changes.files.flatMap((file, index) => unfolded(entry, file) ? [index] : []));
2873
+ built = { entry, changes: entry.changes, width, version: entry.version, rows: changeRows(entry.changes, width, colour, { open }) };
2874
+ }
2875
+ return built.rows;
2876
+ }
2877
+ // The summary names the branch a session started from while its base commit is still on it.
2878
+ // Git is asked once for each branch the project is on, not at every read.
2879
+ function baseName(task, entry) {
2880
+ try {
2881
+ const home = task.repository ?? controller.repo.root, name = currentBranch(home);
2882
+ if (!name || name === task.branch || task.place?.kind === 'here') return null;
2883
+ if (entry.based?.name !== name) entry.based = { name, on: git(home, ['merge-base', '--is-ancestor', task.base, `refs/heads/${name}`], { allowFailure: true }).status === 0 };
2884
+ return entry.based.on ? name : null;
2885
+ } catch { return null; }
2886
+ }
2887
+ // Asks Git what the session on screen changed. This starts Git, so it runs from a timer of
2888
+ // its own and never while drawing. A read that finds nothing new keeps the rows as they are.
2889
+ function readPatch(key) {
2890
+ patchTimer = null;
2891
+ if (closing || key !== patchKey()) return;
2892
+ const id = selectedId, task = snapshot.tasks.find(row => row.id === id), started = Date.now();
2893
+ const entry = patches.get(key) ?? { changes: null, digest: null, error: null, fold: new Map(), wide: null, version: 0, cursor: 0, top: 0 };
2894
+ // The sessions looked at last are kept. The oldest goes first.
2895
+ patches.delete(key); patches.set(key, entry); if (patches.size > 24) patches.delete(patches.keys().next().value);
2896
+ try {
2897
+ const changes = taskPatch(controller.repo, id), digest = hash(JSON.stringify(changes));
2898
+ entry.error = null;
2899
+ if (digest !== entry.digest) {
2900
+ // The cursor stays on its line of its file while the rows around it change.
2901
+ const old = built.entry === entry ? built.rows : null, at = old?.[entry.cursor], name = at?.file != null ? entry.changes.files[at.file]?.path ?? null : null;
2902
+ const offset = name === null ? 0 : entry.cursor - old.findIndex(row => row.file === at.file), gap = entry.cursor - entry.top;
2903
+ entry.changes = { ...changes, base: (changes.base && baseName(task, entry)) || changes.base }; entry.digest = digest;
2904
+ // Up to five files start unfolded. More start folded, as a list to pick from.
2905
+ if (entry.wide === null && changes.files.length) entry.wide = changes.files.length <= 5;
2906
+ if (built.width) {
2907
+ const rows = patchRows(entry, built.width), index = name === null ? -1 : changes.files.findIndex(file => file.path === name), start = index < 0 ? -1 : rows.findIndex(row => row.file === index);
2908
+ if (start >= 0) { entry.cursor = start + Math.min(offset, rows.filter(row => row.file === index).length - 1); entry.top = Math.max(0, entry.cursor - gap); }
2909
+ }
2910
+ }
2911
+ } catch (error) { entry.error = plain(error.message).replace(/\s+/g, ' ').trim() || 'The changes could not be read.'; }
2912
+ entry.live = ['running', 'preparing'].includes(task?.state); entry.at = Date.now(); entry.took = entry.at - started;
2913
+ draw();
2914
+ }
2915
+ function askPatch(key, wait) { clearTimeout(patchTimer); patchTimer = key ? setTimeout(() => readPatch(key), wait) : null; }
2916
+ // While the session runs its changes are read again: every five seconds, or less often
2917
+ // when a read is slow, so reading never takes more than a twentieth of the time. One more
2918
+ // read follows the end of the run.
2919
+ const changesTimer = setInterval(() => {
2920
+ const key = patchKey(), entry = key ? patches.get(key) : null;
2921
+ if (!entry || closing || patchTimer) return;
2922
+ const live = ['running', 'preparing'].includes(snapshot.tasks.find(row => row.id === selectedId)?.state);
2923
+ if (live ? Date.now() - entry.at >= Math.max(5000, entry.took * 20) : entry.live) askPatch(key, 0);
2924
+ }, 1000);
2925
+ // Focus leaves the Changes view for where it was before: the list, the message box or the chat.
2926
+ function leaveChanges() {
2927
+ const to = changesFrom; changesFrom = null;
2928
+ if (to === ask || !listed() && to !== activity) focusInput(); else if (to === activity && view === 'chat') activity.focus(); else focusBoard();
2929
+ }
2930
+ function showChat() { sessionView = 'chat'; if (screen.focused === changesBox) leaveChanges(); draw(); }
2931
+ // focus: the keys go to the view (d on the list, a click on its name). A command typed in
2932
+ // the message box leaves them there.
2933
+ function showChanges({ focus = false } = {}) {
2934
+ if (!controller || view !== 'chat' || !controller.snapshot().tasks.some(row => row.id === selectedId)) throw new Error('Open a session first.');
2935
+ sessionView = 'changes'; patchAsked = `${controller.repo.root}\0${selectedId}`; askPatch(patchAsked, 0);
2936
+ if (focus && screen.focused !== changesBox) { const from = screen.focused; if (ask._reading) ask.cancel(); changesBox.focus(); changesFrom = [board, ask, activity].includes(from) ? from : null; }
2937
+ draw();
2938
+ }
2939
+ // The switch, and under it the rows in view when Changes shows. Nothing here asks Git.
2940
+ function drawSessionViews(task) {
2941
+ const offered = view === 'chat' && Boolean(task), on = offered && sessionView === 'changes', key = on ? `${controller.repo.root}\0${task.id}` : null, entry = key ? patches.get(key) ?? null : null;
2942
+ // Another session shows, or the view came back: its changes are read once the cursor rests on it.
2943
+ if (key !== patchAsked) { patchAsked = key; askPatch(key, 150); }
2944
+ sessionTabs.hidden = !offered; changesBox.hidden = !on; changesShown = null;
2945
+ if (offered) {
2946
+ // The count is the last one read, also while the chat shows.
2947
+ const known = patches.get(`${controller.repo.root}\0${task.id}`), label = changeSummary(known && !known.error ? known.changes : null);
2948
+ const word = (text, active) => active ? `${colour ? `{${PALETTE.orange}-fg}` : ''}{bold}{underline}${text}{/underline}{/bold}${colour ? '{/}' : ''}` : ink(text, 'muted', colour);
2949
+ sessionTabs.setContent(` ${word('Chat', !on)} ${word(label, on)} `);
2950
+ Object.assign(sessionTabs, { top: activity.top, left: activity.left + 1, width: label.length + 9 });
2951
+ }
2952
+ // The pane went while it had the focus (its session closed elsewhere): the focus goes back.
2953
+ if (!on) { if (screen.focused === changesBox) leaveChanges(); return; }
2954
+ activity.hidden = true;
2955
+ Object.assign(changesBox, { top: activity.top, left: activity.left, width: activity.width, height: activity.height });
2956
+ changesBox.style.border.fg = screen.focused === changesBox ? accent : shade('border');
2957
+ // A row is the cursor's mark, the text, then the scrollbar's column.
2958
+ const high = Math.max(1, changesBox.height - 2), width = Math.max(8, changesBox.width - 4), line = (text, tone) => [{ text: ink(fitWords(text, width), tone, colour), kind: 'note', file: null }];
2959
+ const rows = !entry ? line('Reading changes…', 'muted') : entry.error ? line(entry.error, 'orange') : patchRows(entry, width);
2960
+ const cursor = Math.max(0, Math.min(entry?.cursor ?? 0, rows.length - 1)), most = Math.max(0, rows.length - high);
2961
+ const top = Math.max(cursor - high + 1, Math.min(entry?.top ?? 0, cursor, most));
2962
+ if (entry) { entry.cursor = cursor; entry.top = top; }
2963
+ const thumb = most ? Math.round((high - 1) * top / most) : -1, tone = screen.focused === changesBox ? 'orange' : 'dim';
2964
+ changesBox.setContent(rows.slice(top, top + high).map((row, at) => `${ink(rows.length > 1 && top + at === cursor ? '▌' : ' ', tone, colour)}${row.text}${' '.repeat(Math.max(0, width - blessed.unicode.strWidth(visible(row.text))))}${at === thumb ? ink('┃', 'dim', colour) : ' '}`).join('\n'));
2965
+ changesShown = { key, entry: entry?.error ? null : entry, rows, high, top, cursor };
2966
+ }
2967
+ // Up and Down move the cursor a row, and the rows follow it. The wheel moves the rows, and the cursor stays in view.
2968
+ function moveChanges(step, { rows = false } = {}) {
2969
+ const now = changesShown, entry = now?.entry; if (modal || !entry) return;
2970
+ const last = now.rows.length - 1, most = Math.max(0, now.rows.length - now.high);
2971
+ if (rows) { entry.top = Math.max(0, Math.min(most, entry.top + step)); entry.cursor = Math.max(entry.top, Math.min(entry.top + now.high - 1, entry.cursor, last)); }
2972
+ else { entry.cursor = Math.max(0, Math.min(last, entry.cursor + step)); if (Math.abs(step) > 1) entry.top = Math.max(0, Math.min(most, entry.top + step)); }
2973
+ draw();
2974
+ }
2975
+ // Folds or unfolds the file whose header is on that row.
2976
+ function foldChanges(index) {
2977
+ const now = changesShown, entry = now?.entry, row = now?.rows[index], file = row?.kind === 'file' ? entry?.changes.files[row.file] : null; if (modal || !file) return;
2978
+ entry.fold.set(file.path, !unfolded(entry, file)); entry.version++; entry.cursor = index; draw();
2979
+ }
2980
+ changesBox.key(['up', 'down'], (_, key) => moveChanges(key.name === 'up' ? -1 : 1));
2981
+ changesBox.key(['pageup', 'pagedown'], (_, key) => moveChanges((key.name === 'pageup' ? -1 : 1) * Math.max(1, (changesShown?.high ?? 2) - 1)));
2982
+ changesBox.key(['home', 'end'], (_, key) => moveChanges(key.name === 'home' ? -Infinity : Infinity));
2983
+ changesBox.key(['enter', 'space'], () => foldChanges(changesShown?.cursor));
2984
+ changesBox.key(['escape', ...withShift(['d'])], () => { if (!modal) showChat(); });
2985
+ changesBox.on('wheelup', () => moveChanges(-3, { rows: true })); changesBox.on('wheeldown', () => moveChanges(3, { rows: true }));
2986
+ // A click puts the cursor on its row. On a file's header it also folds or unfolds the file.
2987
+ changesBox.on('click', data => {
2988
+ const now = changesShown, index = now ? now.top + data.y - changesBox.atop - 1 : -1; if (modal || !now?.entry || index < now.top || index >= now.rows.length) return;
2989
+ if (now.rows[index].kind === 'file') foldChanges(index); else { now.entry.cursor = index; draw(); }
2990
+ });
2991
+ // Leaving the message box by a key passes through the list on the way to this view, so
2992
+ // the box counts as where the focus came from for the rest of that key.
2993
+ let leftBox = false;
2994
+ ask.on('blur', () => { leftBox = true; setImmediate(() => { leftBox = false; }); });
2995
+ changesBox.on('focus', from => { const origin = leftBox ? ask : from; if ([board, ask, activity].includes(origin)) changesFrom = origin; draw(); });
2996
+ sessionTabs.on('click', data => { if (!modal) catchAction(() => data.x - sessionTabs.aleft < 6 ? showChat() : showChanges({ focus: true })); });
2997
+ // Kanban
2998
+ kanbanLists.forEach((list, index) => {
2999
+ list.on('select', () => catchAction(() => kanbanAction(index, 'open')));
3000
+ list.key(['left', 'right'], (_, key) => { if (modal) return; const next = kanbanLists[index + (key.name === 'left' ? -1 : 1)]; if (next) { next.focus(); draw(); } });
3001
+ list.key(['S-right', '>'], () => { if (!modal) catchAction(() => kanbanAction(index, 'forward')); });
3002
+ list.key(['S-left', '<'], () => { if (!modal) catchAction(() => kanbanAction(index, 'back')); });
3003
+ list.key(['n', '+'], () => { if (!modal) catchAction(() => kanbanAction(index, 'new')); });
3004
+ list.key(['x', 'delete'], () => { if (!modal) catchAction(() => kanbanAction(index, 'remove')); });
3005
+ list.key('escape', () => focusInput());
3006
+ list.on('focus', () => { lastPane.kanban = list; draw(); });
3007
+ list.on('select item', () => { if (!drawing) scheduleDraw(); });
3008
+ });
3009
+ // Kanban: teammates' Tasks. Arrows move between Tasks, Enter shows one; no key changes one.
3010
+ kanbanTeamList.key(['up', 'down'], (_, key) => { if (modal) return; if (!teamTaskStep(key.name === 'up' ? -1 : 1)) { if (key.name === 'up') (lastPane.kanban ?? kanbanLists[0]).focus(); else focusInput(); } draw(); });
3011
+ kanbanTeamList.key('enter', () => { if (!modal) catchAction(() => openTeamTask()); });
3012
+ kanbanTeamList.on('select', () => { if (!modal) catchAction(() => openTeamTask()); });
3013
+ kanbanTeamList.key('escape', () => focusInput());
3014
+ kanbanTeamList.on('focus', () => { teamTaskStep(0); draw(); });
3015
+ kanbanTeamList.on('select item', () => { if (!drawing) scheduleDraw(); });
3016
+ // Bots
3017
+ botsList.on('select', () => catchAction(() => botsAction('open')));
3018
+ botsList.key(['n', '+'], () => { if (!modal) catchAction(() => botsAction('new')); });
3019
+ botsList.key(withShift(['x']), () => { if (!modal) catchAction(() => botsAction('remove')); });
3020
+ botsList.key('escape', () => focusInput());
3021
+ botsList.on('focus', () => draw());
3022
+ botsList.on('select item', () => { if (!drawing) scheduleDraw(); });
3023
+ // Projects
3024
+ projectsList.on('select', () => catchAction(() => projectsAction('settings')));
3025
+ projectsList.key(['n', '+'], () => { if (!modal) catchAction(() => projectsAction('new')); });
3026
+ projectsList.key(withShift(['o']), () => { if (!modal) catchAction(() => projectsAction('open')); });
3027
+ projectsList.key(withShift(['x']), () => { if (!modal) catchAction(() => projectsAction('archive')); });
3028
+ projectsList.key('escape', () => focusInput());
3029
+ projectsList.on('focus', () => draw());
3030
+ projectsList.on('select item', () => { if (!drawing) scheduleDraw(); });
3031
+ // Usage
3032
+ usageList.on('select', () => catchAction(() => usageAction('open')));
3033
+ usageList.key(withShift(['r']), () => { if (!modal) catchAction(() => usageAction('refresh')); });
3034
+ usageList.key('escape', () => focusInput());
3035
+ usageList.on('focus', () => draw());
3036
+ usageList.on('select item', () => { if (!drawing) scheduleDraw(); });
3037
+ // The screen hears a key before the focused list moves, so a list can be left from its edge:
3038
+ // up to the view's buttons, down to the next pane or the message box.
3039
+ // The empty Bots list is one message over its rows, so every row of it is an edge.
3040
+ const atEdge = (list, key) => list === botsList && !botRows.length || (key.name === 'up' ? (list.selected ?? 0) <= 0 : key.name === 'down' && (list.selected ?? 0) >= list.items.length - 1);
3041
+ screen.on('keypress', (_, key = {}) => {
3042
+ const focused = screen.focused;
3043
+ if (modal || closing || !(focused === composeList || teamLists.includes(focused) || kanbanLists.includes(focused) || [botsList, projectsList, usageList].includes(focused)) || !['up', 'down'].includes(key.name) || !atEdge(focused, key)) return;
3044
+ // Stacked panes (Internal under 100 columns) lead into each other first.
3045
+ const panes = viewPanes(), at = panes.indexOf(focused), stacked = view === 'chats' && screen.width < 100;
3046
+ if (key.name === 'up') { if (stacked && at > 0) panes[at - 1].focus(); else (view === 'kanban' ? newCard : view === 'bots' ? newBot : view === 'projects' ? newProject : listed() || ['chats', 'usage'].includes(view) ? activeTab() : back).focus(); }
3047
+ else if (stacked && at >= 0 && at < panes.length - 1) panes[at + 1].focus();
3048
+ else if (kanbanLists.includes(focused) && teamTaskRows.some(row => row.task)) kanbanTeamList.focus();
3049
+ else focusInput();
3050
+ draw();
3051
+ });
3052
+ // Ctrl-C with text highlighted copies it and stays; with none it quits.
3053
+ screen.key(['C-c'], () => catchAction(() => {
3054
+ const text = mouseSelection.text(); if (!text) return quit();
3055
+ copyText?.(text); mouseSelection.clear(); copied(text);
3056
+ }));
3057
+ // Tab moves between areas: the current tab, the view's buttons, its panes, then the message box.
3058
+ function focusNext(backwards = false) {
3059
+ if (modal || closing) return;
3060
+ // In Sessions the message box comes right after the list, so Tab on a row starts typing; the chat follows it.
3061
+ const panels = listed() ? [activeTab(), board, ask, ...viewPanes().slice(1)] : [activeTab(), ...subButtons(), ...viewPanes(), ask];
3062
+ const index = panels.indexOf(tabs.includes(screen.focused) ? activeTab() : screen.focused);
3063
+ if (screen.focused === ask) ask.cancel();
3064
+ const next = panels[(index + panels.length + (backwards ? -1 : 1)) % panels.length];
3065
+ if (next === board) focusBoard(); else next.focus();
3066
+ draw();
3067
+ }
3068
+ screen.key('tab', () => { if (screen.focused !== ask || palette.hidden) focusNext(); }); screen.key('S-tab', () => focusNext(true));
3069
+ const nativeInput = ask._listener;
3070
+ ask._listener = function(ch, key = {}) {
3071
+ if (!this._reading || typeof this._done !== 'function') return;
3072
+ syncPalette();
3073
+ if (!palette.hidden) {
3074
+ if (key.name === 'up' || key.name === 'down') { palette.select((palette.selected + (key.name === 'up' ? -1 : 1) + paletteValues.length) % paletteValues.length); screen.render(); return; }
3075
+ if (key.name === 'escape') { paletteDismissed = this.getValue(); palette.hide(); screen.render(); return; }
3076
+ if (key.name === 'tab') { this.setValue(`${paletteValues[palette.selected ?? 0].value} `); syncPalette(); screen.render(); return; }
3077
+ if (key.name === 'enter' && key.sequence !== '\n') {
3078
+ const chosen = paletteValues[palette.selected ?? 0];
3079
+ // A bot's whole @name, typed out, is sent as it is: Enter alone adds that bot to the session.
3080
+ const whole = view !== 'chats' && chosen.value.startsWith('@') && this.getValue().trim() === chosen.value;
3081
+ if (chosen.more && !whole) { choosePalette(chosen); return; }
3082
+ this.setValue(chosen.value);
3083
+ }
3084
+ } else if (recall && ['up', 'down'].includes(key.name) && this.getValue() === recall.list[recall.at]) {
3085
+ // Walking through what was sent: Up is older, Down is newer, and Down past the newest empties the box.
3086
+ const at = recall.at + (key.name === 'up' ? -1 : 1);
3087
+ if (at >= recall.list.length) { const dropped = recall.taken; recall = null; this.setValue(''); if (dropped) setMessage('Dropped. It will not be sent.'); }
3088
+ else if (at >= 0) { recall.at = at; this.setValue(recall.list[at]); }
3089
+ paletteDismissed = this.getValue(); syncPalette(); screen.render(); return;
3090
+ } else if (!this.getValue() && ['up', 'down'].includes(key.name)) {
3091
+ // Up in an empty box takes back the newest message that still waits for the turn to end:
3092
+ // it is in the box again, Enter sends it and Down drops it.
3093
+ const taken = key.name === 'up' ? takeBack() : null;
3094
+ if (taken !== null) { const list = [...sentBefore().filter(text => text !== taken.trim()), taken]; recall = { list, at: list.length - 1, taken: true }; this.setValue(taken); paletteDismissed = taken; syncPalette(); setMessage('Taken back. Enter sends it again · ↓ drops it.'); return; }
3095
+ // Up in an empty box brings back the last thing sent. With nothing sent yet, the arrows leave the box as before.
3096
+ const list = key.name === 'up' ? sentBefore() : [];
3097
+ if (list.length) { recall = { list, at: list.length - 1 }; this.setValue(list.at(-1)); paletteDismissed = this.getValue(); syncPalette(); screen.render(); return; }
3098
+ this.cancel(); focusContent(key.name === 'up' && view !== 'kanban' ? viewPanes().length - 1 : 0); return;
3099
+ } else if (key.name === 'left' && this.cursor === 0 && listed() && !key.ctrl && !key.meta && !key.shift) {
3100
+ // Left at the start of the text goes back to the list.
3101
+ this.cancel(); return;
3102
+ }
3103
+ if (key.name === 'tab') return;
3104
+ // Enter sends. A newline inside pasted text arrives as a bare line feed and stays in the message.
3105
+ if (key.name === 'enter' && key.sequence !== '\n') { this._done(null, this.value); return; }
3106
+ const value = nativeInput.call(this, ch, key); syncPalette(); screen.render(); return value;
3107
+ };
3108
+ screen.on('keypress', (ch, key = {}) => {
3109
+ if (modal || closing || screen.focused === ask || busy || minimumSize()) return;
3110
+ const command = key.name ?? ch;
3111
+ if ([...tabs, newCard, newBot, newProject, back].includes(screen.focused) && !['q', '?'].includes(command)) return;
3112
+ if (command === 'q') catchAction(() => quit());
3113
+ else if (command === '?') catchAction(() => dispatch({ name: 'help', args: [] }));
3114
+ else if (command === 'i' && screen.focused === activity) focusInput();
3115
+ // In a session, y or n answers the first approval the lead waits on, and
3116
+ // f allows it and gives the session Full access where that is offered.
3117
+ else if (['y', 'n', 'f'].includes(command) && screen.focused === activity && view === 'chat' && controller) catchAction(async () => {
3118
+ const task = selectedId && !selectedRequest ? controller.snapshot().tasks.find(row => row.id === selectedId) : null, first = task && approvalsFor(task)[0];
3119
+ if (!first) return;
3120
+ if (command === 'f') return offersFull(task, first) ? allowFull(task, first) : undefined;
3121
+ setMessage(answerApproval(first.id, command === 'y' ? 'allow' : 'deny') ? command === 'y' ? 'Allowed.' : 'Denied.' : 'That approval was already answered.');
3122
+ });
3123
+ });
3124
+ // In a session, y approves and n rejects the Task that waits (lib/plan.mjs). An approval of
3125
+ // an action in the same session keeps the two keys: it is answered first, by the handler above.
3126
+ screen.on('keypress', (ch, key = {}) => {
3127
+ if (modal || closing || screen.focused === ask || busy || minimumSize()) return;
3128
+ const command = key.name ?? ch;
3129
+ if (!['y', 'n'].includes(command) || screen.focused !== activity || view !== 'chat' || !controller) return;
3130
+ catchAction(() => {
3131
+ const task = selectedId && !selectedRequest ? controller.snapshot().tasks.find(row => row.id === selectedId) : null;
3132
+ if (task?.plan?.state !== 'proposed' || approvalsFor(task).length) return;
3133
+ decideTask(controller.repo.root, task, command === 'y');
3134
+ });
3135
+ });
3136
+ const interrupted = () => { catchAction(() => quit()); };
3137
+ for (const signal of ['SIGTERM', 'SIGHUP']) process.on(signal, interrupted);
3138
+ screen.on('resize', resize);
3139
+ const timer = setInterval(() => draw(true), refreshMs);
3140
+ // The login item follows this install, so a moved checkout or a new Node still runs schedules.
3141
+ if (listSchedules().some(row => row.enabled)) { try { loginItem.install(); } catch { /* Schedules still run while a terminal is open. */ } }
3142
+ const scheduleTimer = setInterval(() => { try { if (tickBots() + runDue(new Date(), { start: conduct })) draw(); } catch { /* The login item retries. */ } refreshAwake(); }, 30000);
3143
+ const usageTimer = setInterval(() => { if (usageEnabled && !closing && view === 'chat') controller.refreshUsage().then(draw); }, 60000);
3144
+ // Started in a folder that is not a project: open the last project used. Only a first start asks for a name.
3145
+ let here = repo ?? null;
3146
+ if (askProject && !here) { try { here = projectFolder(startDirectory); } catch { /* The disk root is never a project. */ } }
3147
+ let fresh = askProject && here && !teamStart && !knownProject(here);
3148
+ if (fresh) { let last = null; try { last = registeredProjects().at(-1); } catch { /* An unreadable registry asks for a name. */ } if (last) { here = last; fresh = false; } }
3149
+ try {
3150
+ if (here && !fresh) activateProject(here);
3151
+ resize(); focusInput();
3152
+ if (fresh) setImmediate(() => catchAction(() => nameHere(here)));
3153
+ await done;
3154
+ } finally {
3155
+ clearInterval(timer); clearInterval(usageTimer); clearInterval(changesTimer); clearTimeout(patchTimer); for (const signal of ['SIGTERM', 'SIGHUP']) process.off(signal, interrupted);
3156
+ if (!closing) { closing = true; await controller?.close(); claim.release(); screen.destroy(); }
3157
+ }
3158
+ return { terminal: true };
3159
+ }