feat(terminal): add guided goals, tasks, and confirmed deletion

This commit is contained in:
Codex committed 2026-09-28 14:13:21 +08:00
1 parent 8ffeb2897f
commit 78c1ffe1fd
7 files changed
+175 -10

No files matched your search

+74 -4
View File
@@ -46,6 +46,63 @@
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;
let flow = null;
const itemsFor = kind => kind === 'memory' ? mems() : board()[kind === 'goal' ? 'goals' : 'tasks'];
const itemLabel = (kind, item) => kind === 'memory' ? `${item.date} ${item.title || item.text?.slice(0, 40) || '—'}` : item.title;
const findItem = (kind, value) => {
const items = itemsFor(kind);
if (/^[1-9]\d*$/.test(value) && items[Number(value) - 1]) return items[Number(value) - 1];
const matches = items.filter(item => item.id === value || (kind === 'memory' && item.date === value) || item.title?.toLocaleLowerCase() === value.toLocaleLowerCase());
if (!matches.length) throw new Error('notFound');
if (matches.length > 1) throw new Error('ambiguous');
return matches[0];
};
const flowFields = { goal: ['title','description','targetDate','showOnDesktop'], task: ['title','dueDate','goalId','priority'] };
const flowPrompt = () => {
if (flow.operation === 'delete' && !flow.target) return { message: `${t('flow.pick', { kind: t(flow.kind) })}\n${itemsFor(flow.kind).map((item,index) => `${index + 1}. ${itemLabel(flow.kind,item)}`).slice(0,30).join('\n')}\n${t('wizard.controls')}` };
if (flow.operation === 'delete') return { message: t('flow.confirmDelete', { kind: t(flow.kind), title: itemLabel(flow.kind,flow.target) }) };
const field = flowFields[flow.kind][flow.step];
if (!field) return { message: t('flow.confirmCreate', { kind: t(flow.kind), title: flow.draft.title }) };
const items = field === 'goalId' ? board().goals.map((goal,index) => `${index + 1}. ${goal.title}`).slice(0,20).join('\n') || '—' : '';
const value = field === 'goalId' ? board().goals.find(goal => goal.id === flow.draft.goalId)?.title : flow.draft[field];
return { message: `${t(`flow.${field}`, { value: value || '—', items })}\n${t('wizard.controls')}` };
};
const startFlow = (kind, operation, value = '') => {
if (operation === 'delete') { if (!itemsFor(kind).length) return { message: t('empty') }; flow = { kind, operation, target: value ? findItem(kind,value) : null }; return flowPrompt(); }
flow = { kind, operation, step: value ? 1 : 0, draft: kind === 'goal' ? { title: value, description: '', targetDate: '', showOnDesktop: false } : { title: value, dueDate: '', goalId: '', priority: 'normal' } };
return flowPrompt();
};
const answerFlow = input => {
const value = input.trim(), current = flow;
if (value === 'cancel') { flow = null; return { message: t('flow.cancelled'), cancel: true }; }
if (value === 'back') {
if (current.operation === 'delete') current.target = null;
else current.step = Math.max(0,current.step - 1);
return flowPrompt();
}
if (current.operation === 'delete') {
if (!current.target) { if (!value) throw new Error('usage'); current.target = findItem(current.kind,value); return flowPrompt(); }
if (!['yes','y'].includes(value.toLowerCase())) { if (!['no','n'].includes(value.toLowerCase())) throw new Error('confirmAnswer'); flow = null; return { message: t('flow.cancelled'), cancel: true }; }
const item = itemsFor(current.kind).find(record => record.id === current.target.id);
if (!item) { flow = null; throw new Error('notFound'); }
const label = itemLabel(current.kind,item);
actions()[`delete${current.kind[0].toUpperCase()}${current.kind.slice(1)}`](item.id);
flow = null; return { message: t('flow.deleted', { kind: t(current.kind), title: label }) };
}
const field = flowFields[current.kind][current.step], draft = current.draft;
if (!field) {
if (!['yes','y'].includes(value.toLowerCase())) { if (!['no','n'].includes(value.toLowerCase())) throw new Error('confirmAnswer'); flow = null; return { message: t('flow.cancelled'), cancel: true }; }
const item = current.kind === 'goal' ? actions().createGoal(draft) : actions().createTask(draft);
flow = null; return { message: t(current.kind === 'goal' ? 'goalSaved' : 'taskSaved', { title: item.title }) };
}
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 === '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();
};
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') || '—' : '';
@@ -80,6 +137,7 @@
return nextStep();
};
const run = line => {
if (flow) return answerFlow(line);
if (wizard) return answer(line);
const [kind, verb, ...rest] = words(line.trim());
if (!kind) return {};
@@ -107,7 +165,9 @@
const found = mems().filter(item => (!flags.tag?.length || flags.tag.every(tag => (item.tags || []).includes(tag))) && (!flags.query || [item.title, item.text, item.location].join(' ').toLocaleLowerCase().includes(flags.query.toLocaleLowerCase())));
return { message: found.length ? found.slice(0, 30).map(item => `${item.date} ${item.title || item.text?.slice(0, 50) || '—'}`).join('\n') : t('empty') };
}
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) });
return { message: t('goalSaved', { title: goal.title }) };
}
@@ -115,7 +175,14 @@
const goals = board().goals;
return { message: goals.length ? goals.map((goal, index) => `${index + 1}. ${goal.done ? '✓' : '○'} ${goal.title}${goal.targetDate ? ` ${goal.targetDate}` : ''}`).join('\n') : t('empty') };
}
if (kind === 'goal' && verb === 'delete') return startFlow('goal','delete',rest.join(' '));
if (kind === 'goal' && ['done','reopen'].includes(verb)) {
if (!rest.length) throw new Error('usage');
const goal = actions().setGoalDone(findItem('goal',rest.join(' ')).id,verb === 'done');
return { message: t(verb === 'done' ? 'goalCompleted' : 'goalReopened', { title: goal.title }) };
}
if (kind === 'task' && verb === 'add') {
if (!rest.some(part => part.startsWith('--'))) return startFlow('task','add',rest.join(' '));
const { flags, values } = options(rest, ['due','goal','priority']); const task = actions().createTask({ title: requireTitle(values.join(' ')), dueDate: date(flags.due), goalId: resolve(board().goals, flags.goal), priority: flags.priority || 'normal' });
return { message: t('taskSaved', { title: task.title }) };
}
@@ -123,6 +190,7 @@
const tasks = board().tasks;
return { message: tasks.length ? tasks.map((task, index) => `${index + 1}. ${task.done ? '✓' : '○'} ${task.title}${task.dueDate ? ` ${task.dueDate}` : ''}`).join('\n') : t('empty') };
}
if (kind === 'task' && verb === 'delete') return startFlow('task','delete',rest.join(' '));
if (kind === 'task' && ['done', 'reopen'].includes(verb)) {
const index = Number(rest[0]);
if (!Number.isInteger(index) || index < 1 || rest.length !== 1 || !board().tasks[index - 1]) throw new Error('notFound');
@@ -132,7 +200,9 @@
throw new Error('unknownCommand');
};
const finishWizard = () => { wizard = null; };
window.LifeOSTerminalEngine = { run, words, backStep, finishWizard, active: () => Boolean(wizard) };
const cancelSession = () => { wizard = null; flow = null; };
const active = () => Boolean(wizard || flow);
window.LifeOSTerminalEngine = { run, words, backStep, finishWizard, cancelSession, active };
const lines = [], history = [];
let historyIndex = 0, compose = null, noteDraft = '';
@@ -155,7 +225,7 @@
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));
node.querySelector('.terminal-form').addEventListener('submit', event => {
event.preventDefault(); const input = node.querySelector('.terminal-input'), line = input.value.trim(); if (!line && !wizard) return;
event.preventDefault(); const input = node.querySelector('.terminal-input'), line = input.value.trim(); if (!line && !active()) 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'); }
@@ -163,9 +233,9 @@
});
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 === 'Escape' && active()) { event.preventDefault(); event.stopPropagation(); addLine(t('flow.cancelled')); cancelSession(); 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]; } }
if (event.key === 'Tab' && !active()) { const roots = ['help','memory add ','memory list','memory delete','goal add ','goal list','goal delete','task add ','task list','task delete','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; 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); };