TUI for Todoist written with React+Ink.
0

Configure Feed

Select the types of activity you want to include in your feed.

remove EditView and add inline edit commands

Aly Raffauf (Feb 15, 2026, 5:41 PM EST) 7cef3046 c47d0aef

+115 -245
+15 -61
source/app.tsx
··· 5 5 import {TodoistApi, type Task} from '@doist/todoist-api-typescript'; 6 6 import {type View, commands} from './commands.js'; 7 7 import {loadConfig, configPath} from './config.js'; 8 - import EditView from './edit-view.js'; 8 + import {Content} from './content.js'; 9 9 import TaskListView from './task-list-view.js'; 10 10 11 11 const config = loadConfig(); ··· 92 92 const [loading, setLoading] = useState(true); 93 93 const [input, setInput] = useState(''); 94 94 const [message, setMessage] = useState(''); 95 - const [editingField, setEditingField] = useState(false); 96 95 97 96 const refresh = useCallback(async () => { 98 - if (view.type === 'edit') { 99 - return; 100 - } 101 - 102 97 setLoading(true); 103 98 104 99 const projectsStale = ··· 155 150 const args = trimmed.slice(command.prefix.length); 156 151 await command.run(args, ctx); 157 152 } else { 158 - setMessage(`Unknown command: ${trimmed}`); 153 + setMessage(`Unknown command: ${trimmed}. Type ? for help`); 159 154 } 160 155 }; 161 156 ··· 166 161 </Text> 167 162 ); 168 163 169 - const viewLabel = 170 - view.type === 'edit' 171 - ? `dewy ∙ edit: ${view.task.content}` 172 - : `dewy ∙ ${view.query === homeFilter ? 'home' : view.query} ∙ ${ 173 - tasks.length 174 - } ${tasks.length === 1 ? 'item' : 'items'}`; 175 - 176 - const isEditing = view.type === 'edit'; 164 + const viewLabel = `dewy ∙ ${ 165 + view.query === homeFilter ? 'home' : view.query 166 + } ∙ ${tasks.length} ${tasks.length === 1 ? 'item' : 'items'}`; 177 167 178 168 return ( 179 169 <Box flexDirection="column"> ··· 182 172 {viewLabel} 183 173 {refreshIndicator} 184 174 </Text> 185 - {isEditing ? ( 186 - <> 187 - <Box 188 - flexDirection="column" 189 - borderStyle="round" 190 - borderColor="cyan" 191 - paddingX={1} 192 - width={50} 193 - > 194 - <EditView 195 - task={view.task} 196 - api={api} 197 - onBack={() => { 198 - setView({type: 'filter', query: homeFilter}); 199 - refresh(); 200 - }} 201 - onEditingChange={setEditingField} 202 - /> 203 - </Box> 204 - <Text dimColor> 205 - {' '} 206 - {editingField 207 - ? 'Enter to save ∙ Escape to cancel' 208 - : 'Enter to edit ∙ Escape to go back'} 209 - </Text> 210 - </> 211 - ) : ( 212 - <> 213 - <Box 214 - flexDirection="column" 215 - borderStyle="round" 216 - borderColor="cyan" 217 - paddingX={1} 218 - > 219 - <TaskListView tasks={tasks} projects={projects} /> 220 - </Box> 221 - <Text color="yellow">{message || ' '}</Text> 222 - </> 223 - )} 175 + <Box 176 + flexDirection="column" 177 + borderStyle="round" 178 + borderColor="cyan" 179 + paddingX={1} 180 + > 181 + <TaskListView tasks={tasks} projects={projects} /> 182 + </Box> 183 + <Text color="yellow">{message ? <Content text={message} /> : ' '}</Text> 224 184 </Box> 225 - {!isEditing && ( 226 - <CommandInput 227 - input={input} 228 - setInput={setInput} 229 - onSubmit={handleSubmit} 230 - /> 231 - )} 185 + <CommandInput input={input} setInput={setInput} onSubmit={handleSubmit} /> 232 186 </Box> 233 187 ); 234 188 }
+74 -6
source/commands.ts
··· 1 1 import {type TodoistApi, type Task} from '@doist/todoist-api-typescript'; 2 2 3 - export type View = {type: 'filter'; query: string} | {type: 'edit'; task: Task}; 3 + export type View = {type: 'filter'; query: string}; 4 4 5 5 export type CommandContext = { 6 6 api: TodoistApi; ··· 30 30 await api.closeTask(task.id); 31 31 setTasks(tasks.filter(t => t.id !== task.id)); 32 32 } else { 33 - setMessage(`No task #${num}`); 33 + setMessage(`Not found: task ${num}`); 34 34 } 35 35 }, 36 36 }, 37 37 38 38 { 39 39 prefix: 'edit ', 40 - hint: 'edit <number>', 41 - run: async (args, {tasks, setView, setMessage}) => { 40 + hint: 'edit <n> [due|p1-p4|labels|desc|project|title]', 41 + run: async (args, {api, tasks, projects, setTasks, setMessage}) => { 42 + const spaceIdx = args.indexOf(' '); 43 + const numStr = spaceIdx === -1 ? args : args.slice(0, spaceIdx); 44 + const rest = spaceIdx === -1 ? '' : args.slice(spaceIdx + 1).trim(); 45 + const num = Number.parseInt(numStr, 10); 46 + const task = tasks[num - 1]; 47 + 48 + if (!task) { 49 + setMessage(`Not found: task ${num}`); 50 + return; 51 + } 52 + 53 + if (!rest) { 54 + setMessage('Missing field. Use due|p1-p4|labels|desc|project|title'); 55 + return; 56 + } 57 + 58 + let updated: Task; 59 + 60 + if (rest.startsWith('due ')) { 61 + updated = await api.updateTask(task.id, { 62 + dueString: rest.slice(4), 63 + }); 64 + } else if (/^p[1-4]$/.test(rest)) { 65 + updated = await api.updateTask(task.id, { 66 + priority: 5 - Number.parseInt(rest[1]!, 10), 67 + }); 68 + } else if (rest.startsWith('labels ')) { 69 + const labels = rest 70 + .slice(7) 71 + .split(/\s+/) 72 + .map(l => l.replace(/^@/, '')); 73 + updated = await api.updateTask(task.id, {labels}); 74 + } else if (rest.startsWith('desc ')) { 75 + updated = await api.updateTask(task.id, { 76 + description: rest.slice(5), 77 + }); 78 + } else if (rest.startsWith('project ')) { 79 + const name = rest.slice(8).replace(/^#/, ''); 80 + const projectId = [...projects.entries()].find( 81 + ([, n]) => n.toLowerCase() === name.toLowerCase(), 82 + )?.[0]; 83 + if (!projectId) { 84 + setMessage(`Not found: project ${name}`); 85 + return; 86 + } 87 + 88 + updated = await api.moveTask(task.id, {projectId}); 89 + } else if (rest.startsWith('title ')) { 90 + updated = await api.updateTask(task.id, { 91 + content: rest.slice(6), 92 + }); 93 + } else { 94 + setMessage( 95 + `Unknown field: ${ 96 + rest.split(' ')[0] 97 + }. Use due|p1-p4|labels|desc|project|title`, 98 + ); 99 + return; 100 + } 101 + 102 + setTasks(tasks.map(t => (t.id === task.id ? updated : t))); 103 + }, 104 + }, 105 + 106 + { 107 + prefix: 'desc ', 108 + hint: 'desc <number>', 109 + run: async (args, {tasks, setMessage}) => { 42 110 const num = Number.parseInt(args, 10); 43 111 const task = tasks[num - 1]; 44 112 if (task) { 45 - setView({type: 'edit', task}); 113 + setMessage(`desc ${num}: ${task.description || 'n/a'}`); 46 114 } else { 47 - setMessage(`No task #${num}`); 115 + setMessage(`Not found: task ${num}`); 48 116 } 49 117 }, 50 118 },
+23
source/content.tsx
··· 1 + import React from 'react'; 2 + import {Text} from 'ink'; 3 + import Link from 'ink-link'; 4 + import {parseMdLink} from './utils.js'; 5 + 6 + export function Content({text, bold}: {text: string; bold?: boolean}) { 7 + const segments = parseMdLink(text); 8 + return ( 9 + <> 10 + {segments.map((seg, i) => 11 + seg.type === 'link' ? ( 12 + <Link key={i} url={seg.url}> 13 + <Text color="cyan">↗ {seg.text}</Text> 14 + </Link> 15 + ) : ( 16 + <Text key={i} bold={bold}> 17 + {seg.text} 18 + </Text> 19 + ), 20 + )} 21 + </> 22 + ); 23 + }
-156
source/edit-view.tsx
··· 1 - import React, {useState} from 'react'; 2 - import {Text, useInput} from 'ink'; 3 - import TextInput from 'ink-text-input'; 4 - import {type TodoistApi, type Task} from '@doist/todoist-api-typescript'; 5 - import {priorityColor} from './utils.js'; 6 - 7 - type EditViewProps = { 8 - task: Task; 9 - api: TodoistApi; 10 - onBack: () => void; 11 - onEditingChange: (editing: boolean) => void; 12 - }; 13 - 14 - const fields = [ 15 - {key: 'content', label: 'Title', placeholder: '', color: () => undefined}, 16 - { 17 - key: 'description', 18 - label: 'Description', 19 - placeholder: 'n/a', 20 - color: () => 'gray', 21 - }, 22 - {key: 'due', label: 'Due', placeholder: 'no date', color: () => 'magenta'}, 23 - { 24 - key: 'priority', 25 - label: 'Priority', 26 - placeholder: 'none', 27 - color: (v: string) => priorityColor(Number.parseInt(v, 10)), 28 - }, 29 - {key: 'labels', label: 'Labels', placeholder: 'none', color: () => 'yellow'}, 30 - ]; 31 - 32 - function getFieldValue(task: Task, key: string): string { 33 - switch (key) { 34 - case 'content': 35 - return task.content; 36 - case 'description': 37 - return task.description; 38 - case 'due': 39 - return task.due?.string ?? ''; 40 - case 'priority': 41 - return String(5 - task.priority); 42 - case 'labels': 43 - return task.labels.join(', '); 44 - default: 45 - return ''; 46 - } 47 - } 48 - 49 - function buildUpdateArgs(key: string, value: string) { 50 - switch (key) { 51 - case 'content': 52 - return {content: value}; 53 - case 'description': 54 - return {description: value}; 55 - case 'due': 56 - return value ? {dueString: value} : {dueString: 'no date'}; 57 - case 'priority': 58 - return { 59 - priority: Math.max( 60 - 1, 61 - Math.min(4, 5 - (Number.parseInt(value, 10) || 4)), 62 - ), 63 - }; 64 - case 'labels': 65 - return { 66 - labels: value 67 - .split(',') 68 - .map(l => l.trim()) 69 - .filter(Boolean), 70 - }; 71 - default: 72 - return {}; 73 - } 74 - } 75 - 76 - export default function EditView({ 77 - task, 78 - api, 79 - onBack, 80 - onEditingChange, 81 - }: EditViewProps) { 82 - const [cursor, setCursor] = useState(0); 83 - const [editing, _setEditing] = useState(false); 84 - const [editValue, setEditValue] = useState(''); 85 - 86 - const setEditing = (value: boolean) => { 87 - _setEditing(value); 88 - onEditingChange(value); 89 - }; 90 - const [fieldValues, setFieldValues] = useState(() => 91 - Object.fromEntries(fields.map(f => [f.key, getFieldValue(task, f.key)])), 92 - ); 93 - 94 - const handleSave = async (value: string) => { 95 - const field = fields[cursor]!; 96 - const args = buildUpdateArgs(fields[cursor]!.key, value); 97 - await api.updateTask(task.id, args); 98 - setFieldValues(prev => ({...prev, [field.key]: value})); 99 - setEditing(false); 100 - }; 101 - 102 - useInput( 103 - (input, key) => { 104 - if (key.upArrow || input === 'k') { 105 - setCursor(c => Math.max(0, c - 1)); 106 - } 107 - 108 - if (key.downArrow || input === 'j') { 109 - setCursor(c => Math.min(fields.length - 1, c + 1)); 110 - } 111 - 112 - if (key.escape) { 113 - onBack(); 114 - } 115 - 116 - if (key.return && !editing) { 117 - setEditing(true); 118 - setEditValue(fieldValues[fields[cursor]!.key] ?? ''); 119 - } 120 - }, 121 - {isActive: !editing}, 122 - ); 123 - 124 - useInput( 125 - (_input, key) => { 126 - if (key.escape) { 127 - setEditing(false); 128 - } 129 - }, 130 - {isActive: editing}, 131 - ); 132 - 133 - return ( 134 - <> 135 - {fields.map((f, i) => ( 136 - <Text key={f.key}> 137 - {cursor === i ? '> ' : ' '} 138 - {f.label.padEnd(14)} 139 - {cursor === i && editing ? ( 140 - <TextInput 141 - value={editValue} 142 - onChange={setEditValue} 143 - onSubmit={handleSave} 144 - /> 145 - ) : fieldValues[f.key] ? ( 146 - <Text color={f.color(fieldValues[f.key] ?? '')}> 147 - {fieldValues[f.key]} 148 - </Text> 149 - ) : ( 150 - <Text dimColor>{f.placeholder}</Text> 151 - )} 152 - </Text> 153 - ))} 154 - </> 155 - ); 156 - }
+3 -22
source/task-list-view.tsx
··· 1 1 import React from 'react'; 2 2 import {Box, Text} from 'ink'; 3 - import Link from 'ink-link'; 4 3 import {type Task} from '@doist/todoist-api-typescript'; 5 - import {parseMdLink, priorityColor} from './utils.js'; 4 + import {priorityColor} from './utils.js'; 5 + import {Content} from './content.js'; 6 6 7 7 type TaskListViewProps = { 8 8 tasks: Task[]; 9 9 projects: Map<string, string>; 10 10 }; 11 - 12 - function Content({text}: {text: string}) { 13 - const segments = parseMdLink(text); 14 - return ( 15 - <> 16 - {segments.map((seg, i) => 17 - seg.type === 'link' ? ( 18 - <Link key={i} url={seg.url}> 19 - <Text color="cyan">↗ {seg.text}</Text> 20 - </Link> 21 - ) : ( 22 - <Text key={i} bold> 23 - {seg.text} 24 - </Text> 25 - ), 26 - )} 27 - </> 28 - ); 29 - } 30 11 31 12 function ProjectLabel({name}: {name: string | undefined}) { 32 13 if (!name) return null; ··· 80 61 <Text dimColor>{String(i + 1).padStart(numWidth)}.</Text> 81 62 </Box> 82 63 <Text wrap="wrap"> 83 - <Content text={task.content} /> 64 + <Content text={task.content} bold /> 84 65 <ProjectLabel name={projects.get(task.projectId)} /> 85 66 <Labels labels={task.labels} /> 86 67 <DueDate date={task.due?.date} />