workoffdesk 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const TRANSITIONS = {
|
|
2
|
+
UserPromptSubmit: 'working',
|
|
3
|
+
PostToolUse: 'working',
|
|
4
|
+
PostToolUseFailure: 'working',
|
|
5
|
+
Notification: 'waiting',
|
|
6
|
+
Stop: 'idle',
|
|
7
|
+
// A turn ended by an API error, a rate limit or an overload.
|
|
8
|
+
StopFailure: 'idle',
|
|
9
|
+
};
|
|
10
|
+
// SessionStart says which transcript Claude writes from now on, and that a new one is at its prompt. A compaction's status
|
|
11
|
+
// depends on what came before it, so the machine keeps it. SessionEnd comes on /clear too, while Claude goes on: whether
|
|
12
|
+
// Claude ended is read off its process (machine.ts), which the hook only has looked at sooner.
|
|
13
|
+
export const HOOK_EVENTS = [...Object.keys(TRANSITIONS), 'SessionStart', 'SessionEnd', 'PreCompact', 'PostCompact'];
|
|
14
|
+
// Notifications that wait on the user: a permission prompt (which is also how a question from Claude
|
|
15
|
+
// shows) and an MCP server asking for input. Claude also notifies when idle at its prompt for a minute,
|
|
16
|
+
// which is not a wait; one without a type comes from a version that predates the types.
|
|
17
|
+
const ASKS = new Set(['permission_prompt', 'elicitation_dialog']);
|
|
18
|
+
export function nextStatus(prev, event, notificationType) {
|
|
19
|
+
if (event === 'Notification' && notificationType !== undefined && !ASKS.has(notificationType))
|
|
20
|
+
return prev;
|
|
21
|
+
// Asked something before it started (trust this folder?), it starts once that is answered.
|
|
22
|
+
if (event === 'SessionStart')
|
|
23
|
+
return prev === 'starting' || prev === 'waiting' ? 'idle' : prev;
|
|
24
|
+
return TRANSITIONS[event] ?? prev;
|
|
25
|
+
}
|
|
26
|
+
export function notificationFor(prev, next, title) {
|
|
27
|
+
if (!next || prev === next)
|
|
28
|
+
return null;
|
|
29
|
+
if (next === 'waiting')
|
|
30
|
+
return { title, event: 'waiting' };
|
|
31
|
+
if (next === 'idle' && prev === 'working')
|
|
32
|
+
return { title, event: 'idle' };
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { askedOf, leftToScreen } from './asks.js';
|
|
2
|
+
import { heldInTranscript, lastTurn, transcriptTail } from '../transcript/turn.js';
|
|
3
|
+
import { nextStatus, notificationFor } from './status.js';
|
|
4
|
+
import { shownTitle } from '../../../system/store.js';
|
|
5
|
+
// How long a turn that ended with a message still in Claude's own queue waits for Claude to start the turn
|
|
6
|
+
// that message makes, before it is taken as over after all.
|
|
7
|
+
export const HELD_WAIT_MS = 5000;
|
|
8
|
+
// Hook events after which no permission prompt of the session is still open, and those that say its tool ran.
|
|
9
|
+
const ASKS_OVER = new Set(['UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd']);
|
|
10
|
+
const TOOL_DONE = new Set(['PostToolUse', 'PostToolUseFailure']);
|
|
11
|
+
function mover(machine, notifier) {
|
|
12
|
+
return (termId, event, type, said = '') => {
|
|
13
|
+
const meta = machine.sessionMeta(termId);
|
|
14
|
+
if (!meta)
|
|
15
|
+
return;
|
|
16
|
+
const prev = machine.getStatus(termId);
|
|
17
|
+
const next = nextStatus(prev, event, type);
|
|
18
|
+
if (!next || next === prev)
|
|
19
|
+
return;
|
|
20
|
+
machine.setStatus(termId, next);
|
|
21
|
+
const n = notificationFor(prev, next, shownTitle(meta));
|
|
22
|
+
if (n)
|
|
23
|
+
void notifier.notify(termId, n.title, n.event, said).catch(() => { });
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// A permission prompt is a wait from the moment its hook comes, seconds before Claude Code's notification,
|
|
27
|
+
// which says only that one is open.
|
|
28
|
+
export function createPermissionHandler(machine, notifier) {
|
|
29
|
+
const move = mover(machine, notifier);
|
|
30
|
+
return (termId, payload, hook) => {
|
|
31
|
+
if (machine.holdPermission(termId, payload, hook) || leftToScreen(payload))
|
|
32
|
+
move(termId, 'Notification', 'permission_prompt', askedOf(payload));
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// What a hook's event was about, in Claude's words: the reply a turn ended with, or what an MCP server asks.
|
|
36
|
+
// Claude Code's own notification of a prompt names no more than that one is open.
|
|
37
|
+
function saidIn(payload, event) {
|
|
38
|
+
const said = event === 'Notification' ? (payload.notification_type === 'elicitation_dialog' ? payload.message : '') : payload.last_assistant_message;
|
|
39
|
+
return typeof said === 'string' ? said : '';
|
|
40
|
+
}
|
|
41
|
+
export function createHookHandler(machine, notifier) {
|
|
42
|
+
const held = new Map();
|
|
43
|
+
const move = mover(machine, notifier);
|
|
44
|
+
return (termId, payload) => {
|
|
45
|
+
if (!machine.sessionMeta(termId))
|
|
46
|
+
return;
|
|
47
|
+
if (typeof payload.session_id === 'string')
|
|
48
|
+
machine.setClaudeSessionId(termId, payload.session_id);
|
|
49
|
+
void machine.ingest(termId);
|
|
50
|
+
const event = String(payload.hook_event_name ?? '');
|
|
51
|
+
if (TOOL_DONE.has(event))
|
|
52
|
+
machine.asks.toolDone(termId, payload.tool_name, payload.tool_input);
|
|
53
|
+
if (ASKS_OVER.has(event))
|
|
54
|
+
machine.asks.endAll(termId);
|
|
55
|
+
clearTimeout(held.get(termId));
|
|
56
|
+
held.delete(termId);
|
|
57
|
+
if (event === 'SessionEnd')
|
|
58
|
+
return machine.claudeMayHaveQuit(termId);
|
|
59
|
+
if (event === 'PreCompact')
|
|
60
|
+
return machine.compactStarted(termId);
|
|
61
|
+
if (event === 'PostCompact')
|
|
62
|
+
return machine.compactEnded(termId, payload.trigger === 'manual');
|
|
63
|
+
// Claude goes on at once with what was typed into its own queue while it worked (a message sent now,
|
|
64
|
+
// out of its turn), however the turn ended: it is not over, and what waits here waits for the next.
|
|
65
|
+
const ended = event === 'Stop' || event === 'StopFailure';
|
|
66
|
+
if (ended && typeof payload.transcript_path === 'string' && heldInTranscript(payload.transcript_path).length > 0) {
|
|
67
|
+
held.set(termId, setTimeout(() => {
|
|
68
|
+
held.delete(termId);
|
|
69
|
+
// Started with no hook to say so: the next Stop ends it.
|
|
70
|
+
const lines = transcriptTail(payload.transcript_path);
|
|
71
|
+
if (lines && lastTurn(lines)?.over === false)
|
|
72
|
+
return;
|
|
73
|
+
move(termId, event, undefined, saidIn(payload, event));
|
|
74
|
+
}, HELD_WAIT_MS));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
move(termId, event, typeof payload.notification_type === 'string' ? payload.notification_type : undefined, saidIn(payload, event));
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { initialized } from './initialize.js';
|
|
4
|
+
// Claude Code's own commands live in its binary, not in files, so these are written down here, for when
|
|
5
|
+
// Claude Code cannot be asked for its list (initialize.ts).
|
|
6
|
+
const BUILTIN = [
|
|
7
|
+
['agents', 'Manage subagents'],
|
|
8
|
+
['clear', 'Clear the conversation and start over'],
|
|
9
|
+
['compact', 'Compact the conversation to free up context'],
|
|
10
|
+
['config', 'Open settings'],
|
|
11
|
+
['context', 'Show how much context is used'],
|
|
12
|
+
['cost', 'Show what this session has cost'],
|
|
13
|
+
['doctor', 'Check the Claude Code installation'],
|
|
14
|
+
['export', 'Export the conversation'],
|
|
15
|
+
['help', 'Help'],
|
|
16
|
+
['hooks', 'Manage hooks'],
|
|
17
|
+
['init', 'Write a CLAUDE.md for the project'],
|
|
18
|
+
['mcp', 'Manage MCP servers'],
|
|
19
|
+
['memory', 'Edit memory files'],
|
|
20
|
+
['model', 'Switch the model'],
|
|
21
|
+
['permissions', 'Manage permissions'],
|
|
22
|
+
['plugin', 'Manage plugins'],
|
|
23
|
+
['resume', 'Resume an earlier conversation'],
|
|
24
|
+
['review', 'Review a pull request'],
|
|
25
|
+
['rewind', 'Go back to an earlier point'],
|
|
26
|
+
['status', 'Show version, account and connections'],
|
|
27
|
+
['todos', 'Show the todo list'],
|
|
28
|
+
['usage', 'Show how much of the plan is used'],
|
|
29
|
+
];
|
|
30
|
+
const DESCRIPTION_MAX = 120;
|
|
31
|
+
async function readText(path) {
|
|
32
|
+
try {
|
|
33
|
+
return await readFile(path, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function readJson(path) {
|
|
40
|
+
const text = await readText(path);
|
|
41
|
+
if (text === null)
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(text);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function entries(dir) {
|
|
51
|
+
try {
|
|
52
|
+
return (await readdir(dir, { withFileTypes: true })).filter((e) => !e.name.startsWith('.'));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const unquote = (v) => (/^(["']).*\1$/.test(v) ? v.slice(1, -1) : v);
|
|
59
|
+
// The simple keys of a markdown file's front matter, and the text after it. A value of `>` or `|` runs
|
|
60
|
+
// on over the indented lines below it.
|
|
61
|
+
function frontMatter(text) {
|
|
62
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
63
|
+
if (!m)
|
|
64
|
+
return { fields: {}, body: text };
|
|
65
|
+
const fields = {};
|
|
66
|
+
const lines = m[1].split(/\r?\n/);
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[i]);
|
|
69
|
+
if (!kv)
|
|
70
|
+
continue;
|
|
71
|
+
let value = kv[2].trim();
|
|
72
|
+
if (value === '' || /^[>|][+-]?$/.test(value)) {
|
|
73
|
+
const more = [];
|
|
74
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]))
|
|
75
|
+
more.push(lines[++i].trim());
|
|
76
|
+
value = more.join(' ');
|
|
77
|
+
}
|
|
78
|
+
fields[kv[1]] = unquote(value);
|
|
79
|
+
}
|
|
80
|
+
return { fields, body: text.slice(m[0].length) };
|
|
81
|
+
}
|
|
82
|
+
function oneLine(text) {
|
|
83
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
84
|
+
return flat.length <= DESCRIPTION_MAX ? flat : `${flat.slice(0, DESCRIPTION_MAX - 1).trimEnd()}…`;
|
|
85
|
+
}
|
|
86
|
+
const firstLine = (body) => body.split(/\r?\n/).map((l) => l.replace(/^#+\s*/, '').trim()).find((l) => l) ?? '';
|
|
87
|
+
function describe(text, fallbackName) {
|
|
88
|
+
const { fields, body } = frontMatter(text);
|
|
89
|
+
const lines = body.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#'));
|
|
90
|
+
return {
|
|
91
|
+
name: fields.name || fallbackName,
|
|
92
|
+
description: oneLine(fields.description || lines[0] || firstLine(body)),
|
|
93
|
+
hidden: fields['user-invocable'] === 'false',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// <dir>/<folder>/SKILL.md, named by its front matter, else by its folder.
|
|
97
|
+
async function skillsIn(dir) {
|
|
98
|
+
const found = [];
|
|
99
|
+
for (const e of await entries(dir)) {
|
|
100
|
+
if (!e.isDirectory() && !e.isSymbolicLink())
|
|
101
|
+
continue;
|
|
102
|
+
const text = await readText(join(dir, e.name, 'SKILL.md'));
|
|
103
|
+
if (text === null)
|
|
104
|
+
continue;
|
|
105
|
+
const s = describe(text, e.name);
|
|
106
|
+
if (!s.hidden)
|
|
107
|
+
found.push({ name: s.name, description: s.description });
|
|
108
|
+
}
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
// <dir>/**/<name>.md, a folder becoming `folder:` in front of the name.
|
|
112
|
+
async function commandsIn(dir, prefix = '') {
|
|
113
|
+
const found = [];
|
|
114
|
+
for (const e of await entries(dir)) {
|
|
115
|
+
const path = join(dir, e.name);
|
|
116
|
+
if (e.isDirectory())
|
|
117
|
+
found.push(...(await commandsIn(path, `${prefix}${e.name}:`)));
|
|
118
|
+
else if (e.name.endsWith('.md')) {
|
|
119
|
+
const text = await readText(path);
|
|
120
|
+
if (text !== null)
|
|
121
|
+
found.push({ name: prefix + e.name.slice(0, -3), description: describe(text, '').description });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
const both = async (root) => [...(await skillsIn(join(root, 'skills'))), ...(await commandsIn(join(root, 'commands')))];
|
|
127
|
+
const record = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
128
|
+
// The plugins switched on for a session in `cwd`: the user's settings, then the project's, then its local ones.
|
|
129
|
+
async function enabledPlugins(claudeDir, cwd) {
|
|
130
|
+
const on = new Map();
|
|
131
|
+
for (const path of [join(claudeDir, 'settings.json'), join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json')]) {
|
|
132
|
+
for (const [key, value] of Object.entries(record(record(await readJson(path)).enabledPlugins)))
|
|
133
|
+
on.set(key, value === true);
|
|
134
|
+
}
|
|
135
|
+
return new Set([...on].filter(([, v]) => v).map(([k]) => k));
|
|
136
|
+
}
|
|
137
|
+
async function pluginCommands(claudeDir, cwd) {
|
|
138
|
+
const enabled = await enabledPlugins(claudeDir, cwd);
|
|
139
|
+
const installed = record(record(await readJson(join(claudeDir, 'plugins', 'installed_plugins.json'))).plugins);
|
|
140
|
+
const found = [];
|
|
141
|
+
for (const [key, installs] of Object.entries(installed)) {
|
|
142
|
+
if (!enabled.has(key) || !Array.isArray(installs))
|
|
143
|
+
continue;
|
|
144
|
+
const install = installs.map(record).find((i) => i.scope === 'user' || i.projectPath === cwd);
|
|
145
|
+
if (typeof install?.installPath !== 'string')
|
|
146
|
+
continue;
|
|
147
|
+
const plugin = key.split('@')[0];
|
|
148
|
+
for (const c of await both(install.installPath))
|
|
149
|
+
found.push({ ...c, name: `${plugin}:${c.name}` });
|
|
150
|
+
}
|
|
151
|
+
return found;
|
|
152
|
+
}
|
|
153
|
+
// Commands Claude Code takes only in its terminal, which it leaves out of the list it gives a program.
|
|
154
|
+
const TERMINAL_ONLY = [
|
|
155
|
+
['add-dir', 'Add a new working directory'],
|
|
156
|
+
['btw', 'Ask a quick side question without interrupting the main conversation'],
|
|
157
|
+
['export', 'Export the current conversation to a file or clipboard'],
|
|
158
|
+
['help', 'Show help and available commands'],
|
|
159
|
+
['hooks', 'Manage hooks'],
|
|
160
|
+
['memory', 'Edit CLAUDE.md files and memory settings'],
|
|
161
|
+
['permissions', 'Manage permissions'],
|
|
162
|
+
['plugin', 'Manage plugins'],
|
|
163
|
+
['resume', 'Resume a previous conversation'],
|
|
164
|
+
['rewind', 'Restore the code and/or conversation to a previous point'],
|
|
165
|
+
['status', 'Show Claude Code status including version, model, account, API connectivity, and tool statuses'],
|
|
166
|
+
['tasks', 'View and manage everything running in the background'],
|
|
167
|
+
];
|
|
168
|
+
// Claude Code says where a command of the user's or the project's comes from after its description, and a
|
|
169
|
+
// plugin's before it.
|
|
170
|
+
function fromTold(c) {
|
|
171
|
+
// `__` marks one of its own, which its menu does not show.
|
|
172
|
+
if (typeof c.name !== 'string' || !c.name || c.name.startsWith('__'))
|
|
173
|
+
return null;
|
|
174
|
+
const said = typeof c.description === 'string' ? c.description : '';
|
|
175
|
+
const source = c.builtin === true ? 'builtin' : c.name.includes(':') ? 'plugin' : /\(project\)\s*$/.test(said) ? 'project' : 'user';
|
|
176
|
+
const unsourced = said.replace(/\s*\((user|project)\)\s*$/, '');
|
|
177
|
+
const description = source === 'plugin' ? unsourced.replace(/^\([^)]*\)\s*/, '') : unsourced;
|
|
178
|
+
return { name: c.name, description: oneLine(description), source };
|
|
179
|
+
}
|
|
180
|
+
const byNameOrder = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
181
|
+
// What can follow a `/` in a Claude session started in `cwd`: Claude Code's own list when `claudeBin` can be
|
|
182
|
+
// asked for it, else read from the same files Claude Code reads.
|
|
183
|
+
export async function listCommands(o) {
|
|
184
|
+
if (o.claudeBin && o.cwd) {
|
|
185
|
+
const told = await initialized(o.claudeBin, o.cwd).catch(() => null);
|
|
186
|
+
const list = Array.isArray(told?.commands) ? told.commands : null;
|
|
187
|
+
if (list) {
|
|
188
|
+
const byName = new Map();
|
|
189
|
+
for (const c of list.map(fromTold))
|
|
190
|
+
if (c && !byName.has(c.name))
|
|
191
|
+
byName.set(c.name, c);
|
|
192
|
+
for (const [name, description] of TERMINAL_ONLY)
|
|
193
|
+
if (!byName.has(name))
|
|
194
|
+
byName.set(name, { name, description, source: 'builtin' });
|
|
195
|
+
return [...byName.values()].sort(byNameOrder);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return filesListed(o);
|
|
199
|
+
}
|
|
200
|
+
// Read from the same files Claude Code reads. Where two share a name, the earlier source wins: built-in,
|
|
201
|
+
// then the project, the user, a plugin.
|
|
202
|
+
async function filesListed(o) {
|
|
203
|
+
const [project, user, plugin] = await Promise.all([
|
|
204
|
+
o.cwd ? both(join(o.cwd, '.claude')) : [],
|
|
205
|
+
both(o.claudeDir),
|
|
206
|
+
pluginCommands(o.claudeDir, o.cwd),
|
|
207
|
+
]);
|
|
208
|
+
const byName = new Map();
|
|
209
|
+
const add = (source, list) => {
|
|
210
|
+
for (const c of list)
|
|
211
|
+
if (c.name && !byName.has(c.name))
|
|
212
|
+
byName.set(c.name, { ...c, source });
|
|
213
|
+
};
|
|
214
|
+
add('builtin', BUILTIN.map(([name, description]) => ({ name, description })));
|
|
215
|
+
add('project', project);
|
|
216
|
+
add('user', user);
|
|
217
|
+
add('plugin', plugin);
|
|
218
|
+
return [...byName.values()].sort(byNameOrder);
|
|
219
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const run = promisify(execFile);
|
|
6
|
+
// How many paths are offered at most, how deep a folder that is not a git repository is walked, and for how
|
|
7
|
+
// long a listing is reused.
|
|
8
|
+
const MAX_FILES = 5000;
|
|
9
|
+
const MAX_DEPTH = 3;
|
|
10
|
+
const GIT_TIMEOUT_MS = 3000;
|
|
11
|
+
const KEPT_MS = 30_000;
|
|
12
|
+
const SKIPPED = new Set(['node_modules', 'dist', 'build', 'target', 'vendor', '__pycache__']);
|
|
13
|
+
const kept = new Map();
|
|
14
|
+
// The files under cwd an `@` can name, relative to it: what git tracks or would (ignored ones left out);
|
|
15
|
+
// outside a repository, a shallow walk past dot folders and dependency folders.
|
|
16
|
+
export function listFiles(cwd, now = Date.now()) {
|
|
17
|
+
const hit = kept.get(cwd);
|
|
18
|
+
if (hit && now - hit.at < KEPT_MS)
|
|
19
|
+
return hit.files;
|
|
20
|
+
const files = gitFiles(cwd).catch(() => walk(cwd));
|
|
21
|
+
kept.set(cwd, { at: now, files });
|
|
22
|
+
files.catch(() => kept.delete(cwd));
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
async function gitFiles(cwd) {
|
|
26
|
+
const { stdout } = await run('git', ['ls-files', '-co', '--exclude-standard'], { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 });
|
|
27
|
+
return stdout.split('\n').filter(Boolean).slice(0, MAX_FILES);
|
|
28
|
+
}
|
|
29
|
+
async function walk(cwd) {
|
|
30
|
+
const files = [];
|
|
31
|
+
const visit = async (rel, depth) => {
|
|
32
|
+
if (files.length >= MAX_FILES)
|
|
33
|
+
return;
|
|
34
|
+
const entries = await readdir(join(cwd, rel), { withFileTypes: true }).catch(() => []);
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
if (files.length >= MAX_FILES)
|
|
37
|
+
return;
|
|
38
|
+
if (e.name.startsWith('.') || SKIPPED.has(e.name))
|
|
39
|
+
continue;
|
|
40
|
+
const path = rel ? `${rel}/${e.name}` : e.name;
|
|
41
|
+
if (e.isDirectory()) {
|
|
42
|
+
if (depth < MAX_DEPTH)
|
|
43
|
+
await visit(path, depth + 1);
|
|
44
|
+
}
|
|
45
|
+
else if (e.isFile())
|
|
46
|
+
files.push(path);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
await visit('', 1);
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const ASK_MS = 15_000;
|
|
3
|
+
const KEPT_MS = 5 * 60_000;
|
|
4
|
+
const told = new Map();
|
|
5
|
+
// Claude Code's answer in `cwd`, kept a while: it changes only with its settings, plugins and account.
|
|
6
|
+
export function initialized(bin, cwd) {
|
|
7
|
+
const key = `${bin}\n${cwd}`;
|
|
8
|
+
const kept = told.get(key);
|
|
9
|
+
if (kept && Date.now() - kept.at < KEPT_MS)
|
|
10
|
+
return kept.answer;
|
|
11
|
+
const answer = askClaude(bin, cwd);
|
|
12
|
+
told.set(key, { at: Date.now(), answer });
|
|
13
|
+
answer.catch(() => told.get(key)?.answer === answer && told.delete(key));
|
|
14
|
+
return answer;
|
|
15
|
+
}
|
|
16
|
+
// Asked as a program asks it (`-p`, stream-json) before any turn, so nothing goes to the model; without hooks
|
|
17
|
+
// or a transcript, so it leaves no trace.
|
|
18
|
+
function askClaude(bin, cwd) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const child = spawn(bin, ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--no-session-persistence', '--settings', JSON.stringify({ disableAllHooks: true })], { cwd, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
21
|
+
let done = false;
|
|
22
|
+
const finish = (error, answer = {}) => {
|
|
23
|
+
if (done)
|
|
24
|
+
return;
|
|
25
|
+
done = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
child.kill();
|
|
28
|
+
if (error)
|
|
29
|
+
reject(error);
|
|
30
|
+
else
|
|
31
|
+
resolve(answer);
|
|
32
|
+
};
|
|
33
|
+
const timer = setTimeout(() => finish(new Error('claude did not answer initialize')), ASK_MS);
|
|
34
|
+
child.on('error', finish);
|
|
35
|
+
child.on('exit', () => finish(new Error('claude ended before answering initialize')));
|
|
36
|
+
let out = '';
|
|
37
|
+
child.stdout.setEncoding('utf8');
|
|
38
|
+
child.stdout.on('data', (chunk) => {
|
|
39
|
+
out += chunk;
|
|
40
|
+
for (let end = out.indexOf('\n'); end >= 0; end = out.indexOf('\n')) {
|
|
41
|
+
const line = out.slice(0, end);
|
|
42
|
+
out = out.slice(end + 1);
|
|
43
|
+
let msg;
|
|
44
|
+
try {
|
|
45
|
+
msg = JSON.parse(line);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (msg?.type !== 'control_response')
|
|
51
|
+
continue;
|
|
52
|
+
const answer = msg.response?.response;
|
|
53
|
+
return answer && typeof answer === 'object' ? finish(null, answer) : finish(new Error('claude answered initialize with nothing'));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
child.stdin.on('error', () => { });
|
|
57
|
+
child.stdin.end(`${JSON.stringify({ type: 'control_request', request_id: 'initialize', request: { subtype: 'initialize' } })}\n`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { initialized } from './initialize.js';
|
|
2
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
3
|
+
// What /model is given is typed at the prompt: a name, an id, `opus[1m]`, never anything else.
|
|
4
|
+
const VALUE = /^[\w.:[\]-]{1,40}$/;
|
|
5
|
+
// The models Claude Code offers in `cwd`, in its own order; empty when it cannot be asked.
|
|
6
|
+
export async function listModels(bin, cwd) {
|
|
7
|
+
const told = await initialized(bin, cwd).catch(() => null);
|
|
8
|
+
if (!Array.isArray(told?.models))
|
|
9
|
+
return [];
|
|
10
|
+
return told.models.flatMap((m) => {
|
|
11
|
+
if (!m || typeof m !== 'object')
|
|
12
|
+
return [];
|
|
13
|
+
const r = m;
|
|
14
|
+
const value = str(r.value);
|
|
15
|
+
if (!VALUE.test(value))
|
|
16
|
+
return [];
|
|
17
|
+
const efforts = r.supportsEffort === true && Array.isArray(r.supportedEffortLevels) ? r.supportedEffortLevels.filter((l) => typeof l === 'string') : [];
|
|
18
|
+
return [{ value, name: str(r.displayName) || value, about: str(r.description), efforts }];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// A choice Claude Code is asking for (a permission prompt, a question, a picker), read off the screen:
|
|
2
|
+
// the transcript has no record of it. Options are lines in one column, the current one marked ❯ and the
|
|
3
|
+
// others indented to match; descriptions that wrap continue further in. Claude Code moves through them
|
|
4
|
+
// with the arrow keys and takes one with Enter (number keys do nothing in some of them).
|
|
5
|
+
//
|
|
6
|
+
// A question that takes several answers draws a box before each option, ticked with Space, and a Submit
|
|
7
|
+
// (Next while more questions follow) under the options, which sends them with Enter. The arrows reach it
|
|
8
|
+
// as though it were one more option, so a cursor on it is `options.length`.
|
|
9
|
+
const CONTEXT_LINES = 15;
|
|
10
|
+
const RULE = /^\s*[─━▔═]{3,}\s*$/;
|
|
11
|
+
const CHECKBOX = /^\[([ ✔√]?)\]\s*/;
|
|
12
|
+
const SUBMIT = /^\s*(❯\s*)?(Submit|Next)$/;
|
|
13
|
+
const TYPED = /^Type something\b/i;
|
|
14
|
+
const THIS_SESSION_ONLY = /\bs to use this session only\b/;
|
|
15
|
+
// The pickers a slash command opens: /model's, and the settings dialog /config opens on its Config tab.
|
|
16
|
+
const OPENED_HEADINGS = /^(Select model|Settings\s{2,}Status\s{2,}Config\b.*)$/;
|
|
17
|
+
const PLAN_FILE = /~\/\.claude\/plans\/([\w.-]+\.md)\b/;
|
|
18
|
+
// /rewind lists the messages sent, each with what it changed on a line under it, apart by blank lines.
|
|
19
|
+
const REWIND_HEAD = /^\s*Restore the code and\/or conversation to the point before/;
|
|
20
|
+
const REWIND_HINT = /^\s*Enter to continue\b/;
|
|
21
|
+
export function parseChoices(screen) {
|
|
22
|
+
const lines = screen.split('\n').map((l) => l.trimEnd());
|
|
23
|
+
const rewind = rewindChoices(lines);
|
|
24
|
+
if (rewind !== undefined)
|
|
25
|
+
return rewind;
|
|
26
|
+
// Earlier prompts stay in the scrollback, marked ❯ too: only the last one on screen can be live. A dialog
|
|
27
|
+
// opened while Claude works is drawn above its prompt, whose ❯ under the rule is not the dialog's.
|
|
28
|
+
let row = -1;
|
|
29
|
+
for (let i = lines.length - 1; i >= 0 && row < 0; i--)
|
|
30
|
+
if (lines[i].includes('❯') && !isPromptLine(lines, i))
|
|
31
|
+
row = i;
|
|
32
|
+
if (row < 0)
|
|
33
|
+
return null;
|
|
34
|
+
const onSubmit = SUBMIT.test(lines[row]);
|
|
35
|
+
if (onSubmit) {
|
|
36
|
+
// The options above are all unmarked, indented where the ❯ would go.
|
|
37
|
+
let i = row - 1;
|
|
38
|
+
while (i >= 0 && !/^\s* {2}\d+\. \S/.test(lines[i]))
|
|
39
|
+
i--;
|
|
40
|
+
if (i < 0)
|
|
41
|
+
return null;
|
|
42
|
+
row = i;
|
|
43
|
+
}
|
|
44
|
+
const cur = (onSubmit ? /^(\s*) {2}\d/ : /^(\s*)❯ \S/).exec(lines[row]);
|
|
45
|
+
if (!cur)
|
|
46
|
+
return null;
|
|
47
|
+
const c = cur[1].length;
|
|
48
|
+
// At the very edge, where earlier prompts and what follows them sit too, only numbered lines are options.
|
|
49
|
+
const isOption = (l) => l.slice(0, c).trim() === '' &&
|
|
50
|
+
' ❯↓↑'.includes(l[c] ?? '') &&
|
|
51
|
+
l[c + 1] === ' ' &&
|
|
52
|
+
/^[^\s…]/.test(l.slice(c + 2)) &&
|
|
53
|
+
(c > 0 || /^\d+\. /.test(l.slice(c + 2)));
|
|
54
|
+
const isMore = (l) => l.slice(0, c + 3).trim() === '' && /^[^\s…]/.test(l.trim()) && !SUBMIT.test(l);
|
|
55
|
+
if (!isOption(lines[row]))
|
|
56
|
+
return null;
|
|
57
|
+
let start = row;
|
|
58
|
+
while (start > 0 && (isOption(lines[start - 1]) || isMore(lines[start - 1])))
|
|
59
|
+
start--;
|
|
60
|
+
while (!isOption(lines[start]))
|
|
61
|
+
start++;
|
|
62
|
+
let end = row;
|
|
63
|
+
while (end + 1 < lines.length && (isOption(lines[end + 1]) || isMore(lines[end + 1])))
|
|
64
|
+
end++;
|
|
65
|
+
const options = [];
|
|
66
|
+
let cursor = 0;
|
|
67
|
+
for (let i = start; i <= end; i++) {
|
|
68
|
+
if (isOption(lines[i])) {
|
|
69
|
+
if (i === row)
|
|
70
|
+
cursor = options.length;
|
|
71
|
+
const text = lines[i].slice(c + 2).replace(/^\d+\.\s+/, '');
|
|
72
|
+
const box = CHECKBOX.exec(text);
|
|
73
|
+
const [label, ...detail] = text.slice(box?.[0].length ?? 0).split(/\s{2,}/);
|
|
74
|
+
options.push({
|
|
75
|
+
label,
|
|
76
|
+
detail: detail.join(' '),
|
|
77
|
+
...(box && { ticked: box[1].trim() !== '' }),
|
|
78
|
+
...(TYPED.test(label) && { typed: true }),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const last = options[options.length - 1];
|
|
83
|
+
last.detail = [last.detail, lines[i].trim()].filter(Boolean).join(' ');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (options.length < 2)
|
|
87
|
+
return null;
|
|
88
|
+
const below = lines.slice(end + 1, end + 6);
|
|
89
|
+
const take = below.some((l) => THIS_SESSION_ONLY.test(l)) ? { take: 's' } : {};
|
|
90
|
+
const planFile = below.map((l) => PLAN_FILE.exec(l)?.[1]).find(Boolean);
|
|
91
|
+
if (!options.some((o) => 'ticked' in o)) {
|
|
92
|
+
if (onSubmit)
|
|
93
|
+
return null;
|
|
94
|
+
const context = contextAbove(lines, start);
|
|
95
|
+
const opened = OPENED_HEADINGS.test(context.split('\n')[0].trim()) ? { opened: true } : {};
|
|
96
|
+
return { context, options, cursor, ...take, ...(planFile && { planFile }), ...opened };
|
|
97
|
+
}
|
|
98
|
+
// Ticks with no Submit in sight could not be sent.
|
|
99
|
+
const submit = submitBelow(lines, end);
|
|
100
|
+
if (!submit)
|
|
101
|
+
return null;
|
|
102
|
+
return { context: contextAbove(lines, start), options, cursor: onSubmit ? options.length : cursor, submit };
|
|
103
|
+
}
|
|
104
|
+
// The line being typed at Claude's prompt: ❯ at the edge, right under the prompt's rule.
|
|
105
|
+
function isPromptLine(lines, i) {
|
|
106
|
+
return lines[i].startsWith('❯') && i > 0 && /^─{10,}/.test(lines[i - 1]);
|
|
107
|
+
}
|
|
108
|
+
// The messages /rewind offers to go back to before, one option each; undefined when /rewind is not up.
|
|
109
|
+
function rewindChoices(lines) {
|
|
110
|
+
let head = -1;
|
|
111
|
+
for (let i = lines.length - 1; i >= 0 && head < 0; i--)
|
|
112
|
+
if (REWIND_HEAD.test(lines[i]))
|
|
113
|
+
head = i;
|
|
114
|
+
if (head < 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
let hint = head + 1;
|
|
117
|
+
while (hint < lines.length && !REWIND_HINT.test(lines[hint]))
|
|
118
|
+
hint++;
|
|
119
|
+
if (hint === lines.length)
|
|
120
|
+
return undefined;
|
|
121
|
+
const blocks = [[]];
|
|
122
|
+
for (const l of lines.slice(head + 1, hint)) {
|
|
123
|
+
if (l.trim())
|
|
124
|
+
blocks[blocks.length - 1].push(l);
|
|
125
|
+
else if (blocks[blocks.length - 1].length)
|
|
126
|
+
blocks.push([]);
|
|
127
|
+
}
|
|
128
|
+
const entries = blocks.filter((b) => b.length);
|
|
129
|
+
const cursor = entries.findIndex((b) => b[0].includes('❯'));
|
|
130
|
+
if (entries.length < 2 || cursor < 0)
|
|
131
|
+
return null;
|
|
132
|
+
return {
|
|
133
|
+
opened: true,
|
|
134
|
+
context: lines[head].trim(),
|
|
135
|
+
options: entries.map(([first, ...rest]) => ({ label: first.replace(/^\s*[❯↑↓]?\s*/, ''), detail: rest.map((l) => l.trim()).join(' ') })),
|
|
136
|
+
cursor,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// The Submit (or Next) right under the options, a blank line or a rule between them at most.
|
|
140
|
+
function submitBelow(lines, end) {
|
|
141
|
+
for (let i = end + 1; i <= end + 2 && i < lines.length; i++) {
|
|
142
|
+
const m = SUBMIT.exec(lines[i]);
|
|
143
|
+
if (m)
|
|
144
|
+
return m[2];
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
// What the choice is about: the lines above it, back to a rule or a screenful's worth.
|
|
149
|
+
function contextAbove(lines, start) {
|
|
150
|
+
const above = [];
|
|
151
|
+
for (let i = start - 1; i >= 0 && above.length < CONTEXT_LINES && !RULE.test(lines[i]); i--)
|
|
152
|
+
above.unshift(lines[i]);
|
|
153
|
+
const indent = Math.min(...above.filter(Boolean).map((l) => l.length - l.trimStart().length));
|
|
154
|
+
return above
|
|
155
|
+
.map((l) => (QUESTION_TABS.test(l.trim()) ? questionsOf(l) : l.slice(indent)))
|
|
156
|
+
.join('\n')
|
|
157
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
158
|
+
.trim();
|
|
159
|
+
}
|
|
160
|
+
// Several questions asked at once have a row of tabs over them, `← ☒ Color ☐ Fruits ✔ Submit →`, a box
|
|
161
|
+
// ticked for each answered; said as `✓ Color · ○ Fruits`.
|
|
162
|
+
const QUESTION_TABS = /^←\s.*[☐☒].*→$/;
|
|
163
|
+
function questionsOf(line) {
|
|
164
|
+
return [...line.matchAll(/([☐☒])\s(.+?)(?=\s{2,}|\s*→$)/g)].map(([, box, name]) => `${box === '☒' ? '✓' : '○'} ${name}`).join(' · ');
|
|
165
|
+
}
|
|
166
|
+
const UP = '\x1b[A';
|
|
167
|
+
const DOWN = '\x1b[B';
|
|
168
|
+
// Moves to option `to` and takes it. An answer to be typed is only moved onto: Enter on it while empty
|
|
169
|
+
// declines the question, so the text and its Enter come after (ClaudeScreen.choose).
|
|
170
|
+
export function keysToChoose(c, to) {
|
|
171
|
+
const move = to > c.cursor ? DOWN.repeat(to - c.cursor) : UP.repeat(c.cursor - to);
|
|
172
|
+
return c.options[to]?.typed ? move : `${move}${c.take ?? '\r'}`;
|
|
173
|
+
}
|
|
174
|
+
// Presses Space on each option whose tick differs from `ticked`, then goes on down to Submit and presses
|
|
175
|
+
// Enter. `ticked` must not hold an answer of one's own: Space in it would be typed.
|
|
176
|
+
export function keysToTick(c, ticked) {
|
|
177
|
+
let at = c.cursor;
|
|
178
|
+
const moveTo = (to) => {
|
|
179
|
+
const keys = to > at ? DOWN.repeat(to - at) : UP.repeat(at - to);
|
|
180
|
+
at = to;
|
|
181
|
+
return keys;
|
|
182
|
+
};
|
|
183
|
+
let keys = '';
|
|
184
|
+
c.options.forEach((o, i) => {
|
|
185
|
+
if (!!o.ticked !== ticked.includes(i))
|
|
186
|
+
keys += `${moveTo(i)} `;
|
|
187
|
+
});
|
|
188
|
+
return `${keys}${moveTo(c.options.length)}\r`;
|
|
189
|
+
}
|