feat(terminal): guide memory creation with prompts
This commit is contained in:
1 parent
8a1dee3c49
commit
8ffeb2897f
12 files changed
+133
-27
No files matched your search
+62
-14
@@ -4,6 +4,7 @@
|
||||
const actions = () => window.LifeOSActions;
|
||||
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; };
|
||||
const words = line => {
|
||||
const result = []; let current = '', quote = '', escaped = false, started = false;
|
||||
for (const char of line) {
|
||||
@@ -34,6 +35,7 @@
|
||||
};
|
||||
const resolve = (items, value) => {
|
||||
if (!value) return '';
|
||||
if (/^[1-9]\d*$/.test(value) && items[Number(value) - 1]) return items[Number(value) - 1].id;
|
||||
const matches = items.filter(item => item.id === value || item.title.toLocaleLowerCase() === value.toLocaleLowerCase());
|
||||
if (!matches.length) throw new Error('notFound');
|
||||
if (matches.length > 1) throw new Error('ambiguous');
|
||||
@@ -41,8 +43,44 @@
|
||||
};
|
||||
const board = () => actions().board();
|
||||
const requireTitle = value => { if (!value?.trim()) throw new Error('usage'); return value.trim(); };
|
||||
const help = () => ['help', '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 …]', '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 wizardFields = ['title', 'date', 'tags', 'energy', 'mood', 'location', 'taskId'];
|
||||
let wizard = null;
|
||||
const wizardPrompt = () => {
|
||||
if (wizard.step >= wizardFields.length) return { compose: { ...wizard.draft } };
|
||||
const field = wizardFields[wizard.step], tasks = field === 'taskId' ? board().tasks.map((item, index) => `${index + 1}. ${item.title}`).slice(0, 20).join('\n') || '—' : '';
|
||||
const current = field === 'taskId' ? board().tasks.find(item => item.id === wizard.draft.taskId)?.title : field === 'tags' ? wizard.draft.tags.join(', ') : wizard.draft[field];
|
||||
return { message: `${t(`wizard.${field}`, { tasks, value: current || '—' })}\n${t('wizard.controls')}` };
|
||||
};
|
||||
const nextStep = () => { while (wizard.step < wizardFields.length && wizard.seeded.has(wizardFields[wizard.step])) wizard.step++; return wizardPrompt(); };
|
||||
const backStep = () => {
|
||||
if (!wizard) return { message: t('noWizard') };
|
||||
wizard.step = Math.min(wizard.step, wizardFields.length);
|
||||
wizard.step = Math.max(0, wizard.step - 1);
|
||||
return wizardPrompt();
|
||||
};
|
||||
const answer = input => {
|
||||
const value = input.trim();
|
||||
if (value === 'cancel') { wizard = null; return { message: t('cancelled'), cancel: true }; }
|
||||
if (value === 'back') return backStep();
|
||||
const field = wizardFields[wizard.step], draft = wizard.draft;
|
||||
if (field === 'title') { if ((!value && !draft.title) || value.length > 120) throw new Error('invalidTitle'); draft.title = value || draft.title; }
|
||||
if (field === 'date') {
|
||||
const selected = date(value) || draft.date || localDate(0);
|
||||
if (!validDay(selected)) throw new Error('invalidDate');
|
||||
if (mems().some(item => item.date === selected)) throw new Error('memoryExists');
|
||||
draft.date = selected;
|
||||
}
|
||||
if (field === 'tags' && value) draft.tags = value === '-' ? [] : [...new Set(value.split(/[,,]/).map(tag => tag.trim()).filter(Boolean))].slice(0, 12);
|
||||
if (field === 'energy') { if (value && !/^[1-5]$/.test(value)) throw new Error('invalidValue'); draft.energy = value ? Number(value) : draft.energy; }
|
||||
if (field === 'mood') { if (value && !['clear','light','cloudy','rain','storm'].includes(value.toLowerCase())) throw new Error('invalidValue'); draft.mood = value ? value.toLowerCase() : draft.mood; }
|
||||
if (field === 'location') { if (value.length > 100) throw new Error('invalidValue'); if (value) draft.location = value === '-' ? '' : value; }
|
||||
if (field === 'taskId' && value) draft.taskId = value === '-' ? '' : resolve(board().tasks, value);
|
||||
wizard.step++;
|
||||
return nextStep();
|
||||
};
|
||||
const run = line => {
|
||||
if (wizard) return answer(line);
|
||||
const [kind, verb, ...rest] = words(line.trim());
|
||||
if (!kind) return {};
|
||||
if (kind === 'help') return { message: help() };
|
||||
@@ -52,10 +90,16 @@
|
||||
openApp(verb); return { message: t('opened', { app: LifeI18n.t(`app.${verb}`) }) };
|
||||
}
|
||||
if (kind === 'memory' && verb === 'add') {
|
||||
const { flags, values } = options(rest, ['date','tag','energy','weather','place','task','text']), title = requireTitle(values.join(' '));
|
||||
const { flags, values } = options(rest, ['date','tag','energy','weather','place','task','text']), title = values.join(' ').trim();
|
||||
const draft = { title, date: date(flags.date) || localDate(0), tags: flags.tag || [], energy: flags.energy === undefined ? 3 : Number(flags.energy), mood: flags.weather || 'cloudy', location: flags.place || '', taskId: resolve(board().tasks, flags.task), text: flags.text || '' };
|
||||
if (mems().some(item => item.date === draft.date)) throw new Error('memoryExists');
|
||||
if (flags.text === undefined) return { compose: draft };
|
||||
if (!validDay(draft.date)) throw new Error('invalidDate');
|
||||
if (mems().some(item => item.date === draft.date) && (flags.date || flags.text !== undefined)) throw new Error('memoryExists');
|
||||
if (!Number.isInteger(draft.energy) || draft.energy < 1 || draft.energy > 5 || !['clear','light','cloudy','rain','storm'].includes(draft.mood)) throw new Error('invalidValue');
|
||||
if (flags.text === undefined) {
|
||||
wizard = { draft, step: 0, seeded: new Set([...(title ? ['title'] : []), ...Object.keys(flags).map(key => ({ tag: 'tags', weather: 'mood', place: 'location', task: 'taskId' }[key] || key))]) };
|
||||
return nextStep();
|
||||
}
|
||||
requireTitle(title);
|
||||
const memory = actions().createMemory(draft); return { message: t('memorySaved', { title: memory.title, date: memory.date }) };
|
||||
}
|
||||
if (kind === 'memory' && verb === 'list') {
|
||||
@@ -87,10 +131,11 @@
|
||||
}
|
||||
throw new Error('unknownCommand');
|
||||
};
|
||||
window.LifeOSTerminalEngine = { run, words };
|
||||
const finishWizard = () => { wizard = null; };
|
||||
window.LifeOSTerminalEngine = { run, words, backStep, finishWizard, active: () => Boolean(wizard) };
|
||||
|
||||
const lines = [], history = [];
|
||||
let historyIndex = 0, compose = null;
|
||||
let historyIndex = 0, compose = null, noteDraft = '';
|
||||
const addLine = (value, kind = 'result') => { lines.push({ value, kind }); if (lines.length > 150) lines.shift(); };
|
||||
const output = node => {
|
||||
const screen = node.querySelector('.terminal-output'); if (!screen) return;
|
||||
@@ -101,30 +146,33 @@
|
||||
const composer = (node, active) => {
|
||||
node.querySelector('.terminal-compose').hidden = !active;
|
||||
node.querySelector('.terminal-form').hidden = active;
|
||||
if (active) { node.querySelector('.terminal-note').value = ''; node.querySelector('.terminal-note').focus(); }
|
||||
if (active) { node.querySelector('.terminal-note').value = noteDraft; node.querySelector('.terminal-note').focus(); }
|
||||
else node.querySelector('.terminal-input').focus();
|
||||
};
|
||||
window.terminalApp = node => {
|
||||
if (node.querySelector('.terminal-shell') && node.dataset.terminalLocale === lang()) { output(node); return; }
|
||||
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 ></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="cancel">${t('cancel')}</button></div></div></div>`;
|
||||
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 ></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));
|
||||
node.querySelector('.terminal-form').addEventListener('submit', event => {
|
||||
event.preventDefault(); const input = node.querySelector('.terminal-input'), line = input.value.trim(); if (!line) return;
|
||||
history.push(line); historyIndex = history.length; input.value = ''; addLine(`lifeos > ${line}`, 'command');
|
||||
try { const result = run(line); if (result.clear) lines.length = 0; if (result.message) addLine(result.message); if (result.compose) { compose = result.compose; addLine(t('notePrompt')); composer(node, true); } }
|
||||
event.preventDefault(); const input = node.querySelector('.terminal-input'), line = input.value.trim(); if (!line && !wizard) return;
|
||||
if (line) history.push(line); historyIndex = history.length; input.value = ''; addLine(`lifeos > ${line || '↵'}`, 'command');
|
||||
try { const result = run(line); if (result.clear) lines.length = 0; if (result.message) addLine(result.message); if (result.compose) { compose = result.compose; addLine(t('notePrompt')); composer(node, true); } if (result.cancel) { compose = null; noteDraft = ''; } }
|
||||
catch (error) { addLine(t(error.message in LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal ? error.message : 'unknownCommand'), 'error'); }
|
||||
output(node);
|
||||
});
|
||||
node.querySelector('.terminal-input').addEventListener('keydown', event => {
|
||||
const input = event.currentTarget;
|
||||
if (event.key === 'Escape' && wizard) { event.preventDefault(); event.stopPropagation(); addLine(t('cancelled')); finishWizard(); output(node); return; }
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); historyIndex = Math.max(0, Math.min(history.length, historyIndex + (event.key === 'ArrowUp' ? -1 : 1))); input.value = history[historyIndex] || ''; }
|
||||
if (event.key === 'Tab') { const roots = ['help','memory add ','memory list','goal add ','goal list','task add ','task list','task done ','task reopen ','open ','clear']; const matches = roots.filter(item => item.startsWith(input.value)); if (matches.length === 1) { event.preventDefault(); input.value = matches[0]; } }
|
||||
});
|
||||
node.querySelector('.terminal-shortcuts').addEventListener('click', event => { const command = event.target.closest('[data-command]')?.dataset.command; if (command) { const input = node.querySelector('.terminal-input'); input.value = command; input.focus(); } });
|
||||
const saveNote = () => { try { const memory = actions().createMemory({ ...compose, text: node.querySelector('.terminal-note').value }); addLine(t('memorySaved', { title: memory.title, date: memory.date })); compose = null; composer(node, false); } catch (error) { addLine(t(error.message in LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal ? error.message : 'unknownCommand'), 'error'); } output(node); };
|
||||
node.querySelector('.terminal-compose').addEventListener('click', event => { const action = event.target.closest('[data-note]')?.dataset.note; if (action === 'save') saveNote(); if (action === 'cancel') { compose = null; addLine(t('cancelled')); composer(node, false); output(node); } });
|
||||
node.querySelector('.terminal-note').addEventListener('keydown', event => { if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); saveNote(); } if (event.key === 'Escape') { event.stopPropagation(); compose = null; addLine(t('cancelled')); composer(node, false); output(node); } });
|
||||
const saveNote = () => { try { const memory = actions().createMemory({ ...compose, text: node.querySelector('.terminal-note').value }); addLine(t('memorySaved', { title: memory.title, date: memory.date })); compose = null; noteDraft = ''; finishWizard(); composer(node, false); } catch (error) { addLine(t(error.message in LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal ? error.message : 'unknownCommand'), 'error'); } output(node); };
|
||||
const cancelNote = () => { compose = null; noteDraft = ''; finishWizard(); addLine(t('cancelled')); composer(node, false); output(node); };
|
||||
node.querySelector('.terminal-compose').addEventListener('click', event => { const action = event.target.closest('[data-note]')?.dataset.note; if (action === 'save') saveNote(); if (action === 'cancel') cancelNote(); if (action === 'back') { compose = null; const result = backStep(); addLine(result.message); composer(node, false); output(node); } });
|
||||
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 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}}`;
|
||||
|
||||
Reference in new issue
Block a user