@tensor.chat/companion 0.4.2 → 0.4.4

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.
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ // The setup's words in Portuguese (Brazil, as on the site).
3
+
4
+ module.exports = {
5
+ setup: 'configuração',
6
+ steps: ['O quê', 'Pastas', 'Comandos', 'Salvar'],
7
+ 'start.title': 'O que a IA pode usar neste computador?',
8
+ 'start.folders': 'Escolher pastas', 'start.foldersNote': 'recomendado',
9
+ 'start.nothing': 'Nada por enquanto', 'start.nothingNote': 'uma conversa pergunta quando precisar de uma pasta',
10
+ 'start.everything': 'Tudo', 'start.everythingNote': 'não recomendado',
11
+ 'start.help': 'A IA lê, altera e exclui arquivos somente onde você permitir aqui.',
12
+ 'start.now': 'Aprovado agora:',
13
+ 'start.later': 'Altere quando quiser:',
14
+ 'places.title': 'Quais pastas a IA pode usar?',
15
+ 'places.loading': 'Procurando pastas…',
16
+ 'places.none': 'Nenhuma sugestão neste computador.',
17
+ 'places.browse': 'Procurar em todas as pastas…', 'places.type': 'Digitar um caminho…', 'places.continue': 'Continuar',
18
+ selected: 'Seleção: {n}',
19
+ 'note.git': '{n} projetos Git', 'note.git1': '1 projeto Git', 'note.project': 'projeto Git',
20
+ 'note.web': 'sites', 'note.srv': 'dados do servidor', 'note.opt': 'programas instalados',
21
+ 'note.etc': 'configurações do sistema', 'note.log': 'logs', 'note.home': 'toda a sua pasta pessoal',
22
+ 'note.readOnly': 'somente leitura para você', 'note.added': 'adicionada por você', 'note.earlier': 'aprovada antes',
23
+ 'note.inside': 'dentro de {path}',
24
+ 'help.home': 'Tudo em {home}, inclusive configurações ocultas e chaves como ~/.ssh.',
25
+ 'help.etc': 'Configurações de todo o sistema. Uma alteração errada ali pode derrubar o servidor.',
26
+ 'help.log': 'Logs do sistema e de seus serviços. Úteis para encontrar erros.',
27
+ 'help.web': 'Arquivos dos sites hospedados neste servidor.',
28
+ 'help.srv': 'Dados dos serviços deste servidor.',
29
+ 'help.opt': 'Programas instalados fora do gerenciador de pacotes.',
30
+ 'help.code': 'Seus projetos de código.',
31
+ 'help.readOnly': 'Aqui você pode ler, mas não escrever. A IA pode fazer o mesmo, nada mais.',
32
+ 'help.browse': 'Escolha qualquer pasta deste computador, como em um gerenciador de arquivos.',
33
+ 'help.type': 'Digite um caminho completo. Tab completa.',
34
+ 'help.continue': 'Próximo: comandos.',
35
+ 'browse.title': 'Procurar', 'browse.thisPc': 'Este Computador',
36
+ 'browse.loading': 'carregando…', 'browse.empty': 'nenhuma pasta', 'browse.noAccess': 'sem acesso', 'browse.link': 'link',
37
+ 'browse.blocked': '{path} está dentro de {parent}, que já está selecionada.',
38
+ 'browse.hidden': 'As pastas ocultas estão visíveis.', 'browse.hiddenOff': 'As pastas ocultas não são mostradas.',
39
+ 'type.title': 'Digite o caminho de uma pasta', 'type.label': 'Caminho',
40
+ 'type.notFound': 'A pasta {path} não existe.',
41
+ 'everything.title': 'Compartilhar tudo?',
42
+ 'everything.body': 'A IA pode ler, alterar e excluir qualquer arquivo que {user} consiga acessar neste computador.',
43
+ 'everything.root': 'Você é root: isso inclui cada arquivo do sistema, inclusive o próprio sistema.',
44
+ 'everything.free': 'Assim a IA pode trabalhar livremente neste servidor. Não recomendado.',
45
+ 'everything.no': 'Não, voltar', 'everything.yes': 'Sim, compartilhar tudo',
46
+ 'everything.summary': 'Tudo neste computador',
47
+ 'shell.title': 'A IA pode executar comandos aqui?',
48
+ 'shell.body': 'Os comandos são executados como {user}, com acesso total à Internet. Eles não se limitam às pastas compartilhadas.',
49
+ 'shell.keep': 'Manter como está', 'shell.keepNote': 'até {date}',
50
+ 'shell.no': 'Agora não', 'shell.noNote': 'uma conversa pergunta antes quando precisar deles',
51
+ 'shell.off': 'Desativar', 'shell.offNote': 'uma conversa pergunta antes quando precisar deles',
52
+ 'shell.d7': 'Sim, por 7 dias', 'shell.d30': 'Sim, por 30 dias', 'shell.d365': 'Sim, por um ano',
53
+ 'review.title': 'Revisar e salvar',
54
+ 'review.folders': 'Pastas', 'review.commands': 'Comandos',
55
+ 'review.noFolders': 'nenhuma — uma conversa pergunta quando precisar de uma',
56
+ 'review.removed': 'aprovação removida',
57
+ 'review.cmdNo': 'não aprovados — uma conversa pergunta antes',
58
+ 'review.cmdUntil': 'aprovados até {date}',
59
+ 'review.save': 'Salvar', 'review.back': 'Voltar', 'review.quit': 'Sair sem salvar',
60
+ 'review.where': 'Salvo somente neste computador, em {file}',
61
+ keyNames: { space: 'espaço', enter: 'enter', esc: 'esc', tab: 'tab', backspace: 'backspace' },
62
+ keys: { move: 'mover', select: 'escolher', toggle: 'marcar', back: 'voltar', quit: 'sair', open: 'abrir', close: 'fechar', openClose: 'abrir/fechar', up: 'pasta acima', hidden: 'ocultas', done: 'concluir', tab: 'completar', add: 'adicionar' },
63
+ 'done.saved': 'Salvo neste computador.',
64
+ 'done.unchanged': 'Nada foi alterado.',
65
+ 'done.web': 'Quando uma conversa no tensor.chat compartilhar uma dessas pastas, este computador confirma isso sozinho.',
66
+ 'done.run': 'Inicie o Companion com:',
67
+ 'done.noTty': 'A configuração precisa de um terminal interativo. Sem um, use approve-folder e approve-shell.',
68
+ };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ // The setup's words in Russian. A count comes after a colon ("Выбрано: 3"), so no
3
+ // word has to take one of the three plural forms.
4
+
5
+ module.exports = {
6
+ setup: 'настройка',
7
+ steps: ['Что', 'Папки', 'Команды', 'Сохранение'],
8
+ 'start.title': 'Что ИИ может использовать на этом компьютере?',
9
+ 'start.folders': 'Выбрать папки', 'start.foldersNote': 'рекомендуется',
10
+ 'start.nothing': 'Пока ничего', 'start.nothingNote': 'чат спросит, когда ему понадобится папка',
11
+ 'start.everything': 'Всё', 'start.everythingNote': 'не рекомендуется',
12
+ 'start.help': 'ИИ читает, изменяет и удаляет файлы только там, где вы это здесь разрешите.',
13
+ 'start.now': 'Сейчас разрешено:',
14
+ 'start.later': 'Изменить можно в любой момент:',
15
+ 'places.title': 'Какие папки может использовать ИИ?',
16
+ 'places.loading': 'Ищем папки…',
17
+ 'places.none': 'На этом компьютере нет предложений.',
18
+ 'places.browse': 'Обзор всех папок…', 'places.type': 'Ввести путь…', 'places.continue': 'Продолжить',
19
+ selected: 'Выбрано: {n}',
20
+ 'note.git': 'Git-проектов: {n}', 'note.git1': '1 Git-проект', 'note.project': 'Git-проект',
21
+ 'note.web': 'сайты', 'note.srv': 'данные сервера', 'note.opt': 'установленные программы',
22
+ 'note.etc': 'настройки системы', 'note.log': 'журналы', 'note.home': 'вся ваша домашняя папка',
23
+ 'note.readOnly': 'для вас только чтение', 'note.added': 'добавлена вами', 'note.earlier': 'разрешена ранее',
24
+ 'note.inside': 'внутри {path}',
25
+ 'help.home': 'Всё в {home}, включая скрытые настройки и ключи, например ~/.ssh.',
26
+ 'help.etc': 'Настройки всей системы. Неверное изменение здесь может остановить сервер.',
27
+ 'help.log': 'Журналы системы и её служб. Помогают находить ошибки.',
28
+ 'help.web': 'Файлы сайтов, которые отдаёт этот сервер.',
29
+ 'help.srv': 'Данные служб на этом сервере.',
30
+ 'help.opt': 'Программы, установленные в обход менеджера пакетов.',
31
+ 'help.code': 'Ваши проекты с кодом.',
32
+ 'help.readOnly': 'Здесь вы можете читать, но не писать. ИИ может то же самое, не больше.',
33
+ 'help.browse': 'Выберите любую папку на этом компьютере, как в файловом менеджере.',
34
+ 'help.type': 'Введите полный путь. Tab дополняет его.',
35
+ 'help.continue': 'Далее: команды.',
36
+ 'browse.title': 'Обзор', 'browse.thisPc': 'Этот компьютер',
37
+ 'browse.loading': 'загрузка…', 'browse.empty': 'нет папок', 'browse.noAccess': 'нет доступа', 'browse.link': 'ссылка',
38
+ 'browse.blocked': '{path} находится внутри {parent}, а эта папка уже выбрана.',
39
+ 'browse.hidden': 'Скрытые папки показаны.', 'browse.hiddenOff': 'Скрытые папки не показываются.',
40
+ 'type.title': 'Введите путь к папке', 'type.label': 'Путь',
41
+ 'type.notFound': 'Папки {path} не существует.',
42
+ 'everything.title': 'Открыть доступ ко всему?',
43
+ 'everything.body': 'ИИ сможет читать, изменять и удалять любой файл, доступный пользователю {user} на этом компьютере.',
44
+ 'everything.root': 'Вы root: это все файлы системы, включая саму систему.',
45
+ 'everything.free': 'Так ИИ сможет свободно работать на этом сервере. Не рекомендуется.',
46
+ 'everything.no': 'Нет, назад', 'everything.yes': 'Да, открыть доступ ко всему',
47
+ 'everything.summary': 'Всё на этом компьютере',
48
+ 'shell.title': 'Может ли ИИ выполнять здесь команды?',
49
+ 'shell.body': 'Команды выполняются от имени {user}, с полным доступом в Интернет. Они не ограничены общими папками.',
50
+ 'shell.keep': 'Оставить как есть', 'shell.keepNote': 'до {date}',
51
+ 'shell.no': 'Не сейчас', 'shell.noNote': 'чат сначала спросит, когда они понадобятся',
52
+ 'shell.off': 'Отключить', 'shell.offNote': 'чат сначала спросит, когда они понадобятся',
53
+ 'shell.d7': 'Да, на 7 дней', 'shell.d30': 'Да, на 30 дней', 'shell.d365': 'Да, на год',
54
+ 'review.title': 'Проверка и сохранение',
55
+ 'review.folders': 'Папки', 'review.commands': 'Команды',
56
+ 'review.noFolders': 'нет — чат спросит, когда ему понадобится папка',
57
+ 'review.removed': 'разрешение отозвано',
58
+ 'review.cmdNo': 'не разрешены — чат сначала спросит',
59
+ 'review.cmdUntil': 'разрешены до {date}',
60
+ 'review.save': 'Сохранить', 'review.back': 'Назад', 'review.quit': 'Выйти без сохранения',
61
+ 'review.where': 'Сохраняется только на этом компьютере, в {file}',
62
+ keyNames: { space: 'пробел', enter: 'Enter', esc: 'Esc', tab: 'Tab', backspace: 'Backspace' },
63
+ keys: { move: 'перемещение', select: 'выбор', toggle: 'отметить', back: 'назад', quit: 'выход', open: 'открыть', close: 'закрыть', openClose: 'открыть/закрыть', up: 'папка выше', hidden: 'скрытые', done: 'готово', tab: 'дополнить', add: 'добавить' },
64
+ 'done.saved': 'Сохранено на этом компьютере.',
65
+ 'done.unchanged': 'Ничего не изменилось.',
66
+ 'done.web': 'Когда чат на tensor.chat откроет доступ к одной из этих папок, этот компьютер подтвердит это сам.',
67
+ 'done.run': 'Запустите Companion командой:',
68
+ 'done.noTty': 'Для настройки нужен интерактивный терминал. Без него используйте approve-folder и approve-shell.',
69
+ };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ // The setup's words in Chinese (Simplified).
3
+
4
+ module.exports = {
5
+ setup: '设置',
6
+ steps: ['范围', '文件夹', '命令', '保存'],
7
+ 'start.title': 'AI 可以使用这台电脑上的哪些内容?',
8
+ 'start.folders': '选择文件夹', 'start.foldersNote': '推荐',
9
+ 'start.nothing': '暂不共享', 'start.nothingNote': '聊天需要文件夹时会询问',
10
+ 'start.everything': '全部', 'start.everythingNote': '不推荐',
11
+ 'start.help': 'AI 只会在你在这里允许的位置读取、修改和删除文件。',
12
+ 'start.now': '当前已批准:',
13
+ 'start.later': '随时可以更改:',
14
+ 'places.title': 'AI 可以使用哪些文件夹?',
15
+ 'places.loading': '正在查找文件夹…',
16
+ 'places.none': '这台电脑上没有建议。',
17
+ 'places.browse': '浏览所有文件夹…', 'places.type': '输入路径…', 'places.continue': '继续',
18
+ selected: '已选 {n} 个',
19
+ 'note.git': '{n} 个 Git 项目', 'note.git1': '1 个 Git 项目', 'note.project': 'Git 项目',
20
+ 'note.web': '网站', 'note.srv': '服务器数据', 'note.opt': '已安装的程序',
21
+ 'note.etc': '系统设置', 'note.log': '日志', 'note.home': '你的整个主目录',
22
+ 'note.readOnly': '你只能读取', 'note.added': '由你添加', 'note.earlier': '之前已批准',
23
+ 'note.inside': '位于 {path} 内',
24
+ 'help.home': '{home} 中的所有内容,也包括隐藏的设置和密钥,例如 ~/.ssh。',
25
+ 'help.etc': '整个系统的设置。改错一处就可能让服务器停止运行。',
26
+ 'help.log': '系统及其服务的日志。便于排查错误。',
27
+ 'help.web': '此服务器提供的网站文件。',
28
+ 'help.srv': '此服务器上各项服务的数据。',
29
+ 'help.opt': '在软件包管理器之外安装的程序。',
30
+ 'help.code': '你的代码项目。',
31
+ 'help.readOnly': '你在这里只能读取,不能写入。AI 也只有同样的权限。',
32
+ 'help.browse': '像在文件管理器中一样,选择这台电脑上的任意文件夹。',
33
+ 'help.type': '输入完整路径。按 Tab 自动补全。',
34
+ 'help.continue': '下一步:命令。',
35
+ 'browse.title': '浏览', 'browse.thisPc': '此电脑',
36
+ 'browse.loading': '加载中…', 'browse.empty': '没有文件夹', 'browse.noAccess': '无权访问', 'browse.link': '链接',
37
+ 'browse.blocked': '{path} 位于 {parent} 内,而后者已被选择。',
38
+ 'browse.hidden': '隐藏文件夹已显示。', 'browse.hiddenOff': '隐藏文件夹未显示。',
39
+ 'type.title': '输入文件夹路径', 'type.label': '路径',
40
+ 'type.notFound': '文件夹 {path} 不存在。',
41
+ 'everything.title': '共享全部内容?',
42
+ 'everything.body': 'AI 可以读取、修改和删除 {user} 在这台电脑上能访问的所有文件。',
43
+ 'everything.root': '你是 root:这意味着系统中的所有文件,包括系统本身。',
44
+ 'everything.free': '这样 AI 可以在这台服务器上自由工作。不推荐。',
45
+ 'everything.no': '不,返回', 'everything.yes': '是,共享全部',
46
+ 'everything.summary': '这台电脑上的全部内容',
47
+ 'shell.title': 'AI 可以在这里运行命令吗?',
48
+ 'shell.body': '命令以 {user} 身份运行,并可完全访问互联网。命令不限于共享的文件夹。',
49
+ 'shell.keep': '保持不变', 'shell.keepNote': '到 {date} 为止',
50
+ 'shell.no': '暂不', 'shell.noNote': '聊天需要时会先询问',
51
+ 'shell.off': '关闭', 'shell.offNote': '聊天需要时会先询问',
52
+ 'shell.d7': '是,7 天', 'shell.d30': '是,30 天', 'shell.d365': '是,一年',
53
+ 'review.title': '检查并保存',
54
+ 'review.folders': '文件夹', 'review.commands': '命令',
55
+ 'review.noFolders': '无 — 聊天需要时会询问',
56
+ 'review.removed': '已撤销批准',
57
+ 'review.cmdNo': '未批准 — 聊天会先询问',
58
+ 'review.cmdUntil': '已批准,到 {date} 为止',
59
+ 'review.save': '保存', 'review.back': '返回', 'review.quit': '退出且不保存',
60
+ 'review.where': '仅保存在这台电脑上的 {file} 中',
61
+ keyNames: { space: '空格', enter: 'Enter', esc: 'Esc', tab: 'Tab', backspace: '退格' },
62
+ keys: { move: '移动', select: '选择', toggle: '勾选', back: '返回', quit: '退出', open: '展开', close: '收起', openClose: '展开/收起', up: '上一级', hidden: '隐藏项', done: '完成', tab: '补全', add: '添加' },
63
+ 'done.saved': '已保存在这台电脑上。',
64
+ 'done.unchanged': '没有更改。',
65
+ 'done.web': '当 tensor.chat 上的聊天共享其中某个文件夹时,这台电脑会自动确认。',
66
+ 'done.run': '使用以下命令启动 Companion:',
67
+ 'done.noTty': '设置需要交互式终端。没有终端时,请使用 approve-folder 和 approve-shell。',
68
+ };
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+ // How the setup's lines are laid out: wrapped text, columns, the key hints and framed
3
+ // blocks. Widths count what the terminal shows, not the escape codes.
4
+
5
+ const { width, pad, fit } = require('./term');
6
+
7
+ const ESC_SPLIT = /(\x1b\[[0-9;?]*[A-Za-z])/;
8
+ // Chinese and Japanese break between any two characters, but a line never starts
9
+ // with closing punctuation or ends with opening punctuation
10
+ const NO_START = new Set([...'、。,.:;?!)」』】〕》〉…・ー〜%’”),.:;?!%]}']);
11
+ const NO_END = new Set([...'(「『【〔《〈‘“([{']);
12
+
13
+ // The text's runs that stay together on a line, each knowing whether a space came
14
+ // before it. Only a real space separates words: French keeps its no-break space.
15
+ function runs(text) {
16
+ const out = [];
17
+ let run = null;
18
+ let spaced = false;
19
+ let pending = '';
20
+ let prev = '';
21
+ for (const part of String(text).split(ESC_SPLIT)) {
22
+ if (part.startsWith('\x1b[')) { pending += part; continue; }
23
+ for (const ch of part) {
24
+ // codes right before a space end the word before it
25
+ if (/[ \t\r\n]/.test(ch)) { if (run) { run.s += pending; pending = ''; out.push(run); } run = null; spaced = out.length > 0; prev = ''; continue; }
26
+ const w = width(ch);
27
+ if (run && (w === 2 || width(prev) === 2) && !NO_START.has(ch) && !NO_END.has(prev)) { out.push(run); run = null; }
28
+ if (!run) { run = { s: '', w: 0, spaced }; spaced = false; }
29
+ run.s += pending + ch;
30
+ run.w += w;
31
+ pending = '';
32
+ prev = ch;
33
+ }
34
+ }
35
+ if (run) out.push(run);
36
+ if (pending && out.length) out[out.length - 1].s += pending;
37
+ return out;
38
+ }
39
+
40
+ // The colours and weights still switched on after `s`, given those before it
41
+ function sgrAfter(active, s) {
42
+ let on = active;
43
+ for (const [, params] of s.matchAll(/\x1b\[([0-9;]*)m/g)) {
44
+ for (const p of params.split(';')) {
45
+ if (p === '' || p === '0') on = [];
46
+ else if (p === '22') on = on.filter((x) => x !== '1' && x !== '2');
47
+ else if (p === '24' || p === '27') on = on.filter((x) => x !== String(p - 20));
48
+ else if (p === '39') on = on.filter((x) => !/^(3[0-7]|9[0-7])$/.test(x));
49
+ else on = [...on, p];
50
+ }
51
+ }
52
+ return on;
53
+ }
54
+
55
+ // Lines of at most `cols` cells. A styled text keeps its style on every line.
56
+ function wrap(text, cols) {
57
+ const lines = [];
58
+ let line = '';
59
+ let used = 0;
60
+ let on = [];
61
+ const add = (s, w) => { line += s; used += w; on = sgrAfter(on, s); };
62
+ const next = () => {
63
+ lines.push(line + (on.length ? '\x1b[0m' : ''));
64
+ line = on.map((p) => `\x1b[${p}m`).join('');
65
+ used = 0;
66
+ };
67
+ for (const run of runs(text)) {
68
+ const gap = used && run.spaced ? 1 : 0;
69
+ if (used && used + gap + run.w > cols) next();
70
+ else if (gap) add(' ', 1);
71
+ if (used + run.w <= cols) { add(run.s, run.w); continue; }
72
+ // a run longer than a whole line, like a long path, is cut where the line ends
73
+ for (const unit of run.s.match(/(?:\x1b\[[0-9;?]*[A-Za-z])*(?:[^\x1b]|$)/gu)) {
74
+ const w = width(unit);
75
+ if (used && used + w > cols) next();
76
+ add(unit, w);
77
+ }
78
+ }
79
+ if (used) lines.push(line + (on.length ? '\x1b[0m' : ''));
80
+ return lines;
81
+ }
82
+
83
+ // A path too long for its place loses its middle: the start says where, the end which
84
+ function shorten(text, cols) {
85
+ if (width(text) <= cols) return text;
86
+ const chars = [...text];
87
+ let head = '';
88
+ while (width(head) < Math.floor((cols - 1) / 3)) head += chars.shift();
89
+ // both cuts fall on a separator where there is one
90
+ const sep = Math.max(head.lastIndexOf('/'), head.lastIndexOf('\\'));
91
+ if (sep > 0) { chars.unshift(...head.slice(sep + 1)); head = head.slice(0, sep + 1); }
92
+ let tail = '';
93
+ while (chars.length && width(tail) + width(chars[chars.length - 1]) <= cols - 1 - width(head)) tail = chars.pop() + tail;
94
+ const start = tail.search(/[\\/]/);
95
+ if (start > 0) tail = tail.slice(start);
96
+ return `${head}…${tail}`;
97
+ }
98
+
99
+ // A label in its column, always followed by a gap
100
+ function cell(label, col) {
101
+ return pad(label, col - 2) + ' ';
102
+ }
103
+
104
+ function spread(left, right, cols) {
105
+ return left + ' '.repeat(Math.max(1, cols - width(left) - width(right))) + right;
106
+ }
107
+
108
+ // The key hints, on as many lines as the width needs
109
+ function keyLines(S, keys, w) {
110
+ const lines = [''];
111
+ for (const [k, label] of keys) {
112
+ const part = `${S.bold(k)} ${S.dim(label)}`;
113
+ const last = lines[lines.length - 1];
114
+ if (last && width(last) + 3 + width(part) > w) lines.push(part); else lines[lines.length - 1] = last ? `${last} ${part}` : part;
115
+ }
116
+ return lines.map((l) => ' ' + l);
117
+ }
118
+
119
+ // A framed block: a warning in yellow, a note in grey
120
+ function panel(S, lines, tone, w) {
121
+ const edge = (s) => (tone ? S[tone](s) : S.dim(s));
122
+ const inner = [];
123
+ for (const l of lines) inner.push(...(width(l) <= w - 4 ? [l] : wrap(l, w - 4)));
124
+ return [
125
+ ' ' + edge('╭' + '─'.repeat(w - 2) + '╮'),
126
+ ...inner.map((l) => ' ' + edge('│') + ' ' + pad(l, w - 4) + ' ' + edge('│')),
127
+ ' ' + edge('╰' + '─'.repeat(w - 2) + '╯'),
128
+ ];
129
+ }
130
+
131
+ // At most `n` of the lines; a cut note ends in "…"
132
+ function clip(lines, n, cols) {
133
+ if (lines.length <= n) return lines;
134
+ const last = lines[n - 1];
135
+ return [...lines.slice(0, n - 1), width(last) + 2 <= cols ? `${last} …` : fit(last, width(last) - 1)];
136
+ }
137
+
138
+ // The frame's top and bottom for a screen of `rows` lines. Before the list gets fewer
139
+ // than three lines, these give way in turn: a third line of the note, the blank lines
140
+ // on top, the gap above the keys, the rule, the steps, a second line of the note.
141
+ function squeeze({ rows, width: cols, brand, steps, heading, note, rule, keys }) {
142
+ const at = (l) => ' ' + l;
143
+ let noteMax = 3;
144
+ let blanks = true;
145
+ let gap = 1;
146
+ let stepped = true;
147
+ let ruled = true;
148
+ const build = () => {
149
+ const blank = blanks ? [''] : [];
150
+ const said = clip(note, noteMax, cols).map(at);
151
+ return {
152
+ top: [...blank, ...brand.map(at), ...(stepped ? [at(steps)] : []), ...blank, ...heading.map(at), ...blank],
153
+ bottom: [...(said.length ? said : ['']), ...(ruled ? [at(rule)] : []), ...keys],
154
+ gap,
155
+ };
156
+ };
157
+ const giveWay = [() => { noteMax = 2; }, () => { blanks = false; }, () => { gap = 0; }, () => { ruled = false; }, () => { stepped = false; }, () => { noteMax = 1; }];
158
+ let parts = build();
159
+ for (const next of giveWay) {
160
+ if (parts.top.length + parts.bottom.length + parts.gap + 3 <= rows) break;
161
+ next();
162
+ parts = build();
163
+ }
164
+ return parts;
165
+ }
166
+
167
+ module.exports = { wrap, shorten, cell, spread, keyLines, panel, squeeze };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+ // Folders worth offering on this computer: where code, documents and server data
3
+ // usually live. Only folders that exist are offered, each once.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const fsp = fs.promises;
9
+ const CODE_DIRS = ['projects', 'Projects', 'src', 'code', 'Code', 'dev', 'Developer', 'git', 'repos', 'workspace', 'work', path.join('source', 'repos')];
10
+ // Where a server keeps its sites, data, programs, settings and logs
11
+ const SYSTEM = {
12
+ linux: [['/var/www', 'web'], ['/srv', 'srv'], ['/opt', 'opt'], ['/etc', 'etc'], ['/var/log', 'log']],
13
+ };
14
+ const GIT_LOOK_LIMIT = 300;
15
+ const PROJECTS_IN_HOME = 8;
16
+
17
+ async function isDir(p) {
18
+ try { return (await fsp.stat(p)).isDirectory(); } catch (_) { return false; }
19
+ }
20
+
21
+ async function writable(p) {
22
+ try { await fsp.access(p, fs.constants.W_OK); return true; } catch (_) { return false; }
23
+ }
24
+
25
+ async function hasEntries(p) {
26
+ try { return (await fsp.readdir(p)).length > 0; } catch (_) { return false; }
27
+ }
28
+
29
+ async function subfolders(dir) {
30
+ try {
31
+ return (await fsp.readdir(dir, { withFileTypes: true }))
32
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
33
+ .map((d) => path.join(dir, d.name));
34
+ } catch (_) { return []; }
35
+ }
36
+
37
+ // Git projects in a folder and one level below; looks at no more than `limit` folders
38
+ async function countGit(dir, limit = GIT_LOOK_LIMIT) {
39
+ if (await isDir(path.join(dir, '.git'))) return 1;
40
+ const first = (await subfolders(dir)).slice(0, limit);
41
+ let n = 0;
42
+ let looked = first.length;
43
+ for (const sub of first) {
44
+ if (await isDir(path.join(sub, '.git'))) { n++; continue; }
45
+ if (looked >= limit) continue;
46
+ const second = (await subfolders(sub)).slice(0, limit - looked);
47
+ looked += second.length;
48
+ for (const deeper of second) if (await isDir(path.join(deeper, '.git'))) n++;
49
+ }
50
+ return n;
51
+ }
52
+
53
+ // ~/.config/user-dirs.dirs names the desktop, documents and downloads folders, in the
54
+ // computer's language (XDG_DOCUMENTS_DIR="$HOME/Dokumente")
55
+ async function xdgUserDirs(home) {
56
+ const found = {};
57
+ try {
58
+ const text = await fsp.readFile(path.join(home, '.config', 'user-dirs.dirs'), 'utf8');
59
+ for (const m of text.matchAll(/^XDG_(DESKTOP|DOCUMENTS|DOWNLOAD)_DIR="([^"]+)"/gm)) {
60
+ found[m[1]] = m[2].startsWith('$HOME/') ? path.join(home, ...m[2].slice(6).split('/')) : m[2];
61
+ }
62
+ } catch (_) {}
63
+ return found;
64
+ }
65
+
66
+ async function candidates({ platform, home, env, system }) {
67
+ const list = [];
68
+ const add = (p, kind) => { if (p) list.push({ path: p, kind }); };
69
+ for (const d of CODE_DIRS) add(path.join(home, d), 'code');
70
+ if (platform === 'linux') {
71
+ const xdg = await xdgUserDirs(home);
72
+ add(xdg.DOCUMENTS || path.join(home, 'Documents'), 'docs');
73
+ add(xdg.DESKTOP || path.join(home, 'Desktop'), 'docs');
74
+ add(xdg.DOWNLOAD || path.join(home, 'Downloads'), 'docs');
75
+ } else {
76
+ if (platform === 'win32' && env.OneDrive) add(path.join(env.OneDrive, 'Documents'), 'docs');
77
+ for (const d of ['Documents', 'Desktop', 'Downloads']) add(path.join(home, d), 'docs');
78
+ }
79
+ add(home, 'home');
80
+ if (platform === 'win32') add(path.join(env.SystemDrive || 'C:', '\\inetpub', 'wwwroot'), 'web');
81
+ for (const [p, kind] of system) add(p, kind);
82
+ return list;
83
+ }
84
+
85
+ // [{ path, kind, git, readOnly }] - kind: code, project, docs, web, srv, opt, home, etc, log
86
+ async function suggestPlaces({ platform = process.platform, home, env = process.env, system = SYSTEM[platform] || [] } = {}) {
87
+ const seen = new Set();
88
+ const key = (p) => (platform === 'win32' || platform === 'darwin' ? p.toLowerCase() : p);
89
+ const out = [];
90
+ const keep = async (p, kind) => {
91
+ let real;
92
+ try { real = await fsp.realpath(p); } catch (_) { return; }
93
+ if (seen.has(key(real)) || !(await isDir(real))) return;
94
+ // A server folder that is there but empty helps nobody
95
+ if ((kind === 'srv' || kind === 'opt') && !(await hasEntries(real))) return;
96
+ seen.add(key(real));
97
+ const item = { path: real, kind, git: 0, readOnly: !(await writable(real)) };
98
+ if (kind === 'code') item.git = await countGit(real);
99
+ out.push(item);
100
+ };
101
+ const list = await candidates({ platform, home, env, system });
102
+ for (const c of list.filter((x) => x.kind === 'code')) await keep(c.path, c.kind);
103
+ // Git projects right in the home folder, as on many servers (~/shop, ~/api)
104
+ let projects = 0;
105
+ for (const sub of await subfolders(home)) {
106
+ if (projects >= PROJECTS_IN_HOME) break;
107
+ if (await isDir(path.join(sub, '.git'))) { await keep(sub, 'project'); projects++; }
108
+ }
109
+ for (const c of list.filter((x) => x.kind !== 'code')) await keep(c.path, c.kind);
110
+ return out;
111
+ }
112
+
113
+ module.exports = { suggestPlaces, countGit, xdgUserDirs, SYSTEM };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+ // The parts of a terminal the setup screens need: keys in, whole frames out. The
3
+ // screens draw on the alternate screen, so the shell's history stays as it was.
4
+
5
+ const readline = require('readline');
6
+
7
+ const CSI = '\x1b[';
8
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
9
+
10
+ // Colour only where the terminal wants it (https://no-color.org); weight and
11
+ // dimming stay, they carry the layout.
12
+ function styles(color) {
13
+ const sgr = (open, close, isColor) => (s) => ((isColor && !color) ? String(s) : `${CSI}${open}m${s}${CSI}${close}m`);
14
+ return {
15
+ bold: sgr(1, 22), dim: sgr(2, 22), inverse: sgr(7, 27), underline: sgr(4, 24),
16
+ cyan: sgr(36, 39, true), green: sgr(32, 39, true), yellow: sgr(33, 39, true), red: sgr(31, 39, true), gray: sgr(90, 39, true),
17
+ };
18
+ }
19
+
20
+ // Marks that sit on the letter before them, like most Devanagari vowel signs
21
+ const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}]$/u;
22
+
23
+ function charWidth(cp) {
24
+ if (ZERO_WIDTH.test(String.fromCodePoint(cp))) return 0;
25
+ // East Asian wide and fullwidth forms take two cells
26
+ if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3)
27
+ || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60)
28
+ || (cp >= 0xffe0 && cp <= 0xffe6)) return 2;
29
+ return 1;
30
+ }
31
+
32
+ function width(s) {
33
+ let n = 0;
34
+ for (const ch of String(s).replace(ANSI_RE, '')) n += charWidth(ch.codePointAt(0));
35
+ return n;
36
+ }
37
+
38
+ // Cut a line to the screen width, keeping its escape codes; a cut line ends in "…"
39
+ function fit(line, cols) {
40
+ if (width(line) <= cols) return line;
41
+ let out = '';
42
+ let n = 0;
43
+ let full = false;
44
+ for (const part of String(line).split(/(\x1b\[[0-9;?]*[A-Za-z])/)) {
45
+ if (part.startsWith('\x1b[')) { if (!full) out += part; continue; }
46
+ for (const ch of part) {
47
+ const w = charWidth(ch.codePointAt(0));
48
+ if (full || n + w > cols - 1) { full = true; break; }
49
+ out += ch;
50
+ n += w;
51
+ }
52
+ }
53
+ return out + '…' + `${CSI}0m`;
54
+ }
55
+
56
+ function pad(s, cols) {
57
+ return s + ' '.repeat(Math.max(0, cols - width(s)));
58
+ }
59
+
60
+ // Opens the screen: raw keys from `input`, frames to `output`. stop() puts the
61
+ // terminal back the way it was, also after an error.
62
+ function openScreen({ input, output }) {
63
+ const keyHandlers = [];
64
+ const onKey = (str, key) => { for (const h of keyHandlers) h(str, key || {}); };
65
+ const onResize = () => { for (const h of resizeHandlers) h(); };
66
+ const resizeHandlers = [];
67
+ let open = false;
68
+ readline.emitKeypressEvents(input);
69
+ return {
70
+ start() {
71
+ if (input.isTTY && typeof input.setRawMode === 'function') input.setRawMode(true);
72
+ input.on('keypress', onKey);
73
+ output.on('resize', onResize);
74
+ input.resume();
75
+ output.write(`${CSI}?1049h${CSI}?25l`);
76
+ open = true;
77
+ },
78
+ size() { return { cols: Math.max(40, output.columns || 80), rows: Math.max(12, output.rows || 24) }; },
79
+ draw(lines) {
80
+ const { cols, rows } = this.size();
81
+ const shown = lines.slice(0, rows).map((l) => fit(l, cols) + `${CSI}K`);
82
+ output.write(`${CSI}H` + shown.join('\r\n') + `${CSI}0m${CSI}J`);
83
+ },
84
+ onKey(h) { keyHandlers.push(h); },
85
+ onResize(h) { resizeHandlers.push(h); },
86
+ stop() {
87
+ if (!open) return;
88
+ open = false;
89
+ input.removeListener('keypress', onKey);
90
+ output.removeListener('resize', onResize);
91
+ if (input.isTTY && typeof input.setRawMode === 'function') input.setRawMode(false);
92
+ input.pause();
93
+ output.write(`${CSI}0m${CSI}?25h${CSI}?1049l`);
94
+ },
95
+ };
96
+ }
97
+
98
+ module.exports = { styles, width, fit, pad, openScreen };