feat(terminal): add system commands and DevTools bridge

This commit is contained in:
Codex committed 2026-09-28 14:20:12 +08:00
1 parent 78c1ffe1fd
commit 6972a00445
12 files changed
+120 -19

No files matched your search

+52 -4
View File
@@ -2,6 +2,7 @@
(() => {
const t = (key, values) => LifeI18n.t(`terminal.${key}`, values);
const actions = () => window.LifeOSActions;
const startedAt = Date.now();
const localDate = offset => { const value = new Date(); value.setDate(value.getDate() + offset); return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`; };
const date = value => value === 'today' ? localDate(0) : value === 'tomorrow' ? localDate(1) : value || '';
const validDay = value => { const parsed = new Date(`${value}T12:00:00`); return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(parsed.getTime()) && `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}` === value; };
@@ -43,7 +44,7 @@
};
const board = () => actions().board();
const requireTitle = value => { if (!value?.trim()) throw new Error('usage'); return value.trim(); };
const help = () => ['help', 'memory add / memory add "…" — ' + t('guidedHelp'), 'memory add "…" [--date YYYY-MM-DD] [--tag …] [--energy 1-5] [--weather cloudy] [--place …] [--task …] [--text …]', 'memory list [--tag …] [--query …]', 'goal add "…" [--due YYYY-MM-DD] [--description …] [--pin]', 'goal list', 'task add "…" [--due tomorrow] [--goal …] [--priority high|normal|low]', 'task list', 'task done <#> / task reopen <#>', 'open <app> / clear'].join('\n');
const help = () => ['help', 'memory add / memory add "…" — ' + t('guidedHelp'), 'memory add "…" [--date YYYY-MM-DD] [--tag …] [--energy 1-5] [--weather cloudy] [--place …] [--task …] [--text …]', 'memory list [--tag …] [--query …] / memory delete [#|date|title]', 'goal add ["…"] / goal list / goal delete [#|title]', 'goal add "…" --due YYYY-MM-DD [--description …] [--pin]', 'goal done <#> / goal reopen <#>', 'task add ["…"] / task list / task delete [#|title]', 'task add "…" --due tomorrow [--goal …] [--priority high|normal|low]', 'task done <#> / task reopen <#>', 'date / time / datetime / uptime / version', 'status / apps / history / echo "…"', 'open <app> / clear / console'].join('\n');
const wizardFields = ['title', 'date', 'tags', 'energy', 'mood', 'location', 'taskId'];
let wizard = null;
let flow = null;
@@ -98,7 +99,7 @@
if (field === 'title') { if (!value && !draft.title || value.length > (current.kind === 'goal' ? 100 : 140)) throw new Error('invalidTitle'); draft.title = value || draft.title; }
if (field === 'description') { if (value.length > 240) throw new Error('invalidValue'); if (value) draft.description = value === '-' ? '' : value; }
if (field === 'targetDate' || field === 'dueDate') { const selected = value === '-' ? '' : date(value) || draft[field]; if (selected && !validDay(selected)) throw new Error('invalidDate'); draft[field] = selected; }
if (field === 'showOnDesktop') { if (value && !['yes','y','no','n'].includes(value.toLowerCase())) throw new Error('invalidValue'); draft.showOnDesktop = value ? ['yes','y'].includes(value.toLowerCase()) : draft.showOnDesktop; }
if (field === 'showOnDesktop') { if (value && !['yes','y','no','n'].includes(value.toLowerCase())) throw new Error('invalidValue'); const enabled = value ? ['yes','y'].includes(value.toLowerCase()) : draft.showOnDesktop; if (enabled && !draft.targetDate) throw new Error('pinNeedsDate'); draft.showOnDesktop = enabled; }
if (field === 'goalId' && value) draft.goalId = value === '-' ? '' : resolve(board().goals,value);
if (field === 'priority') { if (value && !['high','normal','low'].includes(value.toLowerCase())) throw new Error('invalidPriority'); draft.priority = value ? value.toLowerCase() : draft.priority; }
current.step++; return flowPrompt();
@@ -143,6 +144,18 @@
if (!kind) return {};
if (kind === 'help') return { message: help() };
if (kind === 'clear') return { clear: true };
if (kind === 'date' || kind === 'time' || kind === 'datetime') {
if (verb) throw new Error('usage');
const options = kind === 'date' ? { dateStyle: 'full' } : kind === 'time' ? { timeStyle: 'medium' } : { dateStyle: 'full', timeStyle: 'medium' };
return { message: new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', options).format(new Date()) };
}
if (kind === 'uptime') { if (verb) throw new Error('usage'); const seconds = Math.floor((Date.now() - startedAt) / 1000); return { message: t('uptime', { minutes: Math.floor(seconds / 60), seconds: seconds % 60 }) }; }
if (kind === 'version') { if (verb) throw new Error('usage'); return { message: `Life OS v${window.LifeOSVersion || '1.12.0'}` }; }
if (kind === 'status') { if (verb) throw new Error('usage'); const data = board(); return { message: t('status', { memories: mems().length, goals: data.goals.length, tasks: data.tasks.length }) }; }
if (kind === 'apps') { if (verb) throw new Error('usage'); return { message: Object.keys(APPS).map(id => `${id} — ${LifeI18n.t(`app.${id}`)}`).join('\n') }; }
if (kind === 'history') { if (verb) throw new Error('usage'); return { message: history.slice(-20).map((entry,index) => `${index + 1}. ${entry}`).join('\n') || t('empty') }; }
if (kind === 'echo') return { message: words(line.trim()).slice(1).join(' ') };
if (kind === 'console') return { message: t('consoleHelp') };
if (kind === 'open') {
if (!Object.hasOwn(APPS, verb)) throw new Error('unknownApp');
openApp(verb); return { message: t('opened', { app: LifeI18n.t(`app.${verb}`) }) };
@@ -168,7 +181,7 @@
if (kind === 'memory' && verb === 'delete') return startFlow('memory','delete',rest.join(' '));
if (kind === 'goal' && verb === 'add') {
if (!rest.some(part => part.startsWith('--'))) return startFlow('goal','add',rest.join(' '));
const { flags, values } = options(rest, ['due','description','pin']); const goal = actions().createGoal({ title: requireTitle(values.join(' ')), targetDate: date(flags.due), description: flags.description || '', showOnDesktop: Boolean(flags.pin) });
const { flags, values } = options(rest, ['due','description','pin']); if (flags.pin && !flags.due) throw new Error('pinNeedsDate'); const goal = actions().createGoal({ title: requireTitle(values.join(' ')), targetDate: date(flags.due), description: flags.description || '', showOnDesktop: Boolean(flags.pin) });
return { message: t('goalSaved', { title: goal.title }) };
}
if (kind === 'goal' && verb === 'list') {
@@ -205,6 +218,7 @@
window.LifeOSTerminalEngine = { run, words, backStep, finishWizard, cancelSession, active };
const lines = [], history = [];
let welcomed = false;
let historyIndex = 0, compose = null, noteDraft = '';
const addLine = (value, kind = 'result') => { lines.push({ value, kind }); if (lines.length > 150) lines.shift(); };
const output = node => {
@@ -221,6 +235,7 @@
};
window.terminalApp = node => {
if (node.querySelector('.terminal-shell') && node.dataset.terminalLocale === lang()) { output(node); return; }
if (!welcomed) { lines.unshift({ value: t('welcome', { version: window.LifeOSVersion || '1.12.0' }), kind: 'system' }); welcomed = true; }
node.dataset.terminalLocale = lang();
node.innerHTML = `<div class="terminal-shell"><div class="terminal-header"><strong>Life OS / ${t('title')}</strong><span>${t('hint')}</span></div><div class="terminal-output" role="log" aria-live="polite"></div><div class="terminal-shortcuts"><button type="button" data-command="help">help</button><button type="button" data-command="memory add">${t('memory')}</button><button type="button" data-command="task add ">${t('task')}</button><button type="button" data-command="goal add ">${t('goal')}</button><button type="button" data-command="task list">task list</button></div><form class="terminal-form"><label for="terminal-input">lifeos &gt;</label><input id="terminal-input" class="terminal-input" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="${t('command')}" maxlength="600"><button type="submit">↵</button></form><div class="terminal-compose" hidden><label for="terminal-note">${t('notePrompt')}</label><textarea id="terminal-note" class="terminal-note" rows="5" maxlength="12000"></textarea><div><button type="button" data-note="save">${t('saveNote')}</button><button type="button" data-note="back">${t('back')}</button><button type="button" data-note="cancel">${t('cancel')}</button></div></div></div>`;
output(node); composer(node, Boolean(compose));
@@ -244,7 +259,40 @@
node.querySelector('.terminal-note').addEventListener('input', event => { noteDraft = event.currentTarget.value; });
node.querySelector('.terminal-note').addEventListener('keydown', event => { if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); saveNote(); } if (event.key === 'Escape') { event.stopPropagation(); cancelNote(); } });
};
const consoleOutput = result => {
if (result.clear) lines.length = 0;
if (result.message) addLine(result.message);
if (result.compose) { compose = result.compose; addLine(t('notePrompt')); }
if (result.cancel) { compose = null; noteDraft = ''; }
const node = document.querySelector('#content-terminal');
if (node?.querySelector('.terminal-shell')) { output(node); if (result.compose || result.cancel) composer(node, Boolean(compose)); }
const message = result.compose ? t('consoleNotePrompt') : result.message || (result.clear ? t('cleared') : '');
if (message) console.info('[Life OS]', message);
return message;
};
window.LifeOS = Object.freeze({
run(line) {
const input = String(line || '').trim();
if (compose) { console.info('[Life OS]', t('consoleNotePrompt')); return t('consoleNotePrompt'); }
if (!input && !active()) return t('usage');
try { if (input) history.push(input); addLine(`devtools > ${input || '↵'}`, 'command'); return consoleOutput(run(input)); }
catch (error) { const key = Object.hasOwn(LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal,error.message) ? error.message : 'unknownCommand'; return consoleOutput({ message: t(key) }); }
},
help() { return this.run('help'); },
note(text) {
if (!compose) { console.warn('[Life OS]', t('noNote')); return t('noNote'); }
try {
const memory = actions().createMemory({ ...compose, text: String(text || '') });
compose = null; noteDraft = ''; finishWizard();
const node = document.querySelector('#content-terminal');
if (node?.querySelector('.terminal-shell')) composer(node, false);
return consoleOutput({ message: t('memorySaved', { title: memory.title, date: memory.date }) });
} catch (error) { const key = Object.hasOwn(LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal,error.message) ? error.message : 'unknownCommand'; return consoleOutput({ message: t(key) }); }
},
cancel() { cancelSession(); compose = null; noteDraft = ''; return consoleOutput({ message: t('flow.cancelled'), cancel: true }); }
});
console.info('[Life OS]', t('consoleReady'));
const style = document.createElement('style');
style.textContent = `#content-terminal{padding:0;overflow:hidden}.terminal-shell{height:100%;display:flex;flex-direction:column;background:#111a16;color:#d4efd2;font:13px/1.55 'DM Mono',ui-monospace,monospace}.terminal-header{display:flex;justify-content:space-between;gap:10px;padding:12px 15px;border-bottom:1px solid #375040;color:#a8ed9c}.terminal-header span{color:#94a899;font-size:11px}.terminal-output{flex:1;min-height:0;overflow:auto;padding:15px;white-space:pre-wrap;overflow-wrap:anywhere}.terminal-line{font:inherit;margin:0 0 10px;white-space:pre-wrap}.terminal-line.command{color:#a8ed9c}.terminal-line.error{color:#ff9e99}.terminal-shortcuts{display:flex;gap:6px;overflow:auto;padding:7px 12px;border-top:1px solid #375040}.terminal-shortcuts button,.terminal-compose button,.terminal-form button{border:1px solid #456047;background:#233329;color:#d4efd2;border-radius:7px;padding:5px 9px;white-space:nowrap}.terminal-form{display:flex;align-items:center;gap:8px;border-top:1px solid #375040;padding:10px 14px}.terminal-form label{color:#a8ed9c;white-space:nowrap}.terminal-input{flex:1;min-width:0;background:transparent;color:#f0fff0;border:0;outline:none;font:inherit}.terminal-compose{border-top:1px solid #375040;padding:12px;display:grid;gap:8px}.terminal-compose[hidden],.terminal-form[hidden]{display:none}.terminal-note{width:100%;resize:vertical;background:#1c2a21;color:#f0fff0;border:1px solid #456047;border-radius:7px;padding:10px;font:inherit}.terminal-compose>div{display:flex;gap:8px}@media(max-width:650px){.terminal-header{display:block}.terminal-header span{display:block}.terminal-shell{font-size:12px}}`;
style.textContent = `#content-terminal{padding:0;overflow:hidden}.terminal-shell{height:100%;display:flex;flex-direction:column;background:#111a16;color:#d4efd2;font:13px/1.55 'DM Mono',ui-monospace,monospace}.terminal-header{display:flex;justify-content:space-between;gap:10px;padding:12px 15px;border-bottom:1px solid #375040;color:#a8ed9c}.terminal-header span{color:#94a899;font-size:11px}.terminal-output{flex:1;min-height:0;overflow:auto;padding:15px;white-space:pre-wrap;overflow-wrap:anywhere}.terminal-line{font:inherit;margin:0 0 10px;white-space:pre-wrap}.terminal-line.command,.terminal-line.system{color:#a8ed9c}.terminal-line.error{color:#ff9e99}.terminal-shortcuts{display:flex;gap:6px;overflow:auto;padding:7px 12px;border-top:1px solid #375040}.terminal-shortcuts button,.terminal-compose button,.terminal-form button{border:1px solid #456047;background:#233329;color:#d4efd2;border-radius:7px;padding:5px 9px;white-space:nowrap}.terminal-form{display:flex;align-items:center;gap:8px;border-top:1px solid #375040;padding:10px 14px}.terminal-form label{color:#a8ed9c;white-space:nowrap}.terminal-input{flex:1;min-width:0;background:transparent;color:#f0fff0;border:0;outline:none;font:inherit}.terminal-compose{border-top:1px solid #375040;padding:12px;display:grid;gap:8px}.terminal-compose[hidden],.terminal-form[hidden]{display:none}.terminal-note{width:100%;resize:vertical;background:#1c2a21;color:#f0fff0;border:1px solid #456047;border-radius:7px;padding:10px;font:inherit}.terminal-compose>div{display:flex;gap:8px}@media(max-width:650px){.terminal-header{display:block}.terminal-header span{display:block}.terminal-shell{font-size:12px}}`;
document.head.append(style);
})();