TUI for Todoist written with React+Ink.
0

Configure Feed

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

Add task edit view and edit command, replaced project view with filters

Aly Raffauf (Feb 11, 2026, 11:36 AM EST) 1563868c a60fd026

+210 -32
-1
README.md
··· 54 54 |---------|-------------| 55 55 | `add <task>` | Add a task via quick add | 56 56 | `done <number>` | Complete a task by its number | 57 - | `project <name>` | View tasks in a project | 58 57 | `filter <query>` | View tasks matching a Todoist filter | 59 58 | `today` | View today's tasks | 60 59 | `home` | View the home filter |
+38 -10
source/app.tsx
··· 2 2 import {Box, Text, useApp} from 'ink'; 3 3 import TextInput from 'ink-text-input'; 4 4 import {TodoistApi, type Task} from '@doist/todoist-api-typescript'; 5 - import {commands} from './commands.js'; 5 + import {type View, commands} from './commands.js'; 6 + import {priorityColor} from './utils.js'; 6 7 import {loadConfig, configPath} from './config.js'; 8 + import EditView from './edit-view.js'; 7 9 8 10 const config = loadConfig(); 9 11 const apiToken = config?.apiToken ?? process.env['TODOIST_API_TOKEN']; ··· 23 25 const [tasks, setTasks] = useState<Task[]>([]); 24 26 const [projects, setProjects] = useState<Map<string, string>>(new Map()); 25 27 26 - const [view, setView] = useState< 27 - {type: 'filter'; query: string} | {type: 'project'; projectId: string} 28 - >({ 28 + const [view, setView] = useState<View>({ 29 29 type: 'filter', 30 30 query: homeFilter, 31 31 }); ··· 35 35 const [message, setMessage] = useState(''); 36 36 37 37 const refresh = useCallback(async () => { 38 + if (view.type === 'edit') { 39 + return; 40 + } 41 + 38 42 setLoading(true); 39 43 40 44 const [taskResponse, projectResponse] = await Promise.all([ 41 - view.type === 'filter' 42 - ? api.getTasksByFilter({query: view.query}) 43 - : api.getTasks({projectId: view.projectId}), 45 + api.getTasksByFilter({query: view.query}), 44 46 api.getProjects(), 45 47 ]); 46 48 ··· 88 90 } 89 91 90 92 const viewLabel = 91 - view.type === 'project' 92 - ? `dewy ∙ #${projects.get(view.projectId) ?? view.projectId}` 93 + view.type === 'edit' 94 + ? `dewy ∙ edit: ${view.task.content}` 93 95 : `dewy ∙ ${view.query}`; 94 96 97 + if (view.type === 'edit') { 98 + return ( 99 + <Box flexDirection="column"> 100 + <Text bold color="cyan"> 101 + {viewLabel} 102 + </Text> 103 + <EditView 104 + task={view.task} 105 + api={api} 106 + onBack={() => { 107 + setView({type: 'filter', query: homeFilter}); 108 + refresh(); 109 + }} 110 + /> 111 + </Box> 112 + ); 113 + } 114 + 95 115 return ( 96 116 <Box flexDirection="column"> 97 117 <Text bold color="cyan"> ··· 117 137 '' 118 138 )} 119 139 {task.due?.date ? ( 120 - <Text dimColor color="red"> 140 + <Text dimColor color="magenta"> 121 141 {' '} 122 142 {task.due.date} 143 + </Text> 144 + ) : ( 145 + '' 146 + )} 147 + {task.priority > 1 ? ( 148 + <Text dimColor color={priorityColor(5 - task.priority)}> 149 + {' '} 150 + p{5 - task.priority} 123 151 </Text> 124 152 ) : ( 125 153 ''
+18 -21
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}; 4 + 3 5 export type CommandContext = { 4 6 api: TodoistApi; 5 7 tasks: Task[]; ··· 8 10 setMessage: (msg: string) => void; 9 11 10 12 refresh: () => Promise<void>; 11 - setView: ( 12 - view: 13 - | {type: 'filter'; query: string} 14 - | {type: 'project'; projectId: string}, 15 - ) => void; 16 - 13 + setView: (view: View) => void; 17 14 exit: () => void; 18 15 }; 19 16 type Command = { ··· 38 35 } 39 36 }, 40 37 }, 38 + 39 + { 40 + prefix: 'edit ', 41 + hint: 'edit <number>', 42 + run: async (args, {tasks, setView, setMessage}) => { 43 + const num = Number.parseInt(args, 10); 44 + const task = tasks[num - 1]; 45 + if (task) { 46 + setView({type: 'edit', task}); 47 + } else { 48 + setMessage(`No task #${num}`); 49 + } 50 + }, 51 + }, 52 + 41 53 { 42 54 prefix: 'add ', 43 55 hint: 'add <task>', ··· 61 73 run: async (_args, {refresh, setMessage}) => { 62 74 await refresh(); 63 75 setMessage('↻ refreshed'); 64 - }, 65 - }, 66 - { 67 - prefix: 'project ', 68 - hint: 'project <project>', 69 - run: async (args, {projects, setMessage, setView}) => { 70 - const projectId = [...projects.entries()].find( 71 - ([, n]) => n.toLowerCase() === args.toLowerCase(), 72 - )?.[0]; 73 - if (projectId) { 74 - setView({type: 'project', projectId}); 75 - setMessage(`→ #${args}`); 76 - } else { 77 - setMessage(`Project not found: ${args}`); 78 - } 79 76 }, 80 77 }, 81 78
+142
source/edit-view.tsx
··· 1 + import React, {useState} from 'react'; 2 + import {Box, 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 + }; 12 + 13 + const fields = [ 14 + {key: 'content', label: 'Title', color: () => undefined}, 15 + {key: 'description', label: 'Description', color: () => 'gray'}, 16 + {key: 'due', label: 'Due', color: () => 'magenta'}, 17 + { 18 + key: 'priority', 19 + label: 'Priority', 20 + color: (v: string) => priorityColor(Number.parseInt(v, 10)), 21 + }, 22 + {key: 'labels', label: 'Labels', color: () => 'yellow'}, 23 + ]; 24 + 25 + function getFieldValue(task: Task, key: string): string { 26 + switch (key) { 27 + case 'content': 28 + return task.content; 29 + case 'description': 30 + return task.description; 31 + case 'due': 32 + return task.due?.string ?? ''; 33 + case 'priority': 34 + return String(5 - task.priority); 35 + case 'labels': 36 + return task.labels.join(', '); 37 + default: 38 + return ''; 39 + } 40 + } 41 + 42 + function buildUpdateArgs(key: string, value: string) { 43 + switch (key) { 44 + case 'content': 45 + return {content: value}; 46 + case 'description': 47 + return {description: value}; 48 + case 'due': 49 + return value ? {dueString: value} : {dueString: 'no date'}; 50 + case 'priority': 51 + return { 52 + priority: Math.max( 53 + 1, 54 + Math.min(4, 5 - (Number.parseInt(value, 10) || 4)), 55 + ), 56 + }; 57 + case 'labels': 58 + return { 59 + labels: value 60 + .split(',') 61 + .map(l => l.trim()) 62 + .filter(Boolean), 63 + }; 64 + default: 65 + return {}; 66 + } 67 + } 68 + 69 + export default function EditView({task, api, onBack}: EditViewProps) { 70 + const [cursor, setCursor] = useState(0); 71 + const [editing, setEditing] = useState(false); 72 + const [editValue, setEditValue] = useState(''); 73 + const [fieldValues, setFieldValues] = useState(() => 74 + Object.fromEntries(fields.map(f => [f.key, getFieldValue(task, f.key)])), 75 + ); 76 + 77 + const handleSave = async (value: string) => { 78 + const field = fields[cursor]!; 79 + const args = buildUpdateArgs(fields[cursor]!.key, value); 80 + await api.updateTask(task.id, args); 81 + setFieldValues(prev => ({...prev, [field.key]: value})); 82 + setEditing(false); 83 + }; 84 + 85 + useInput( 86 + (input, key) => { 87 + if (key.upArrow || input === 'k') { 88 + setCursor(c => Math.max(0, c - 1)); 89 + } 90 + 91 + if (key.downArrow || input === 'j') { 92 + setCursor(c => Math.min(fields.length - 1, c + 1)); 93 + } 94 + 95 + if (key.escape) { 96 + onBack(); 97 + } 98 + 99 + if (key.return && !editing) { 100 + setEditing(true); 101 + setEditValue(getFieldValue(task, fields[cursor]!.key)); 102 + } 103 + }, 104 + {isActive: !editing}, 105 + ); 106 + 107 + useInput( 108 + (_input, key) => { 109 + if (key.escape) { 110 + setEditing(false); 111 + } 112 + }, 113 + {isActive: editing}, 114 + ); 115 + 116 + return ( 117 + <Box flexDirection="column"> 118 + {fields.map((f, i) => ( 119 + <Text key={f.key}> 120 + {cursor === i ? '> ' : ' '} 121 + {f.label.padEnd(14)} 122 + {cursor === i && editing ? ( 123 + <TextInput 124 + value={editValue} 125 + onChange={setEditValue} 126 + onSubmit={handleSave} 127 + /> 128 + ) : ( 129 + <Text color={f.color(fieldValues[f.key] ?? '')}> 130 + {fieldValues[f.key]} 131 + </Text> 132 + )} 133 + </Text> 134 + ))} 135 + <Text dimColor> 136 + {editing 137 + ? ' Enter to save ∙ Escape to cancel' 138 + : ' Enter to edit ∙ Escape to go back'} 139 + </Text> 140 + </Box> 141 + ); 142 + }
+12
source/utils.ts
··· 1 + export function priorityColor(displayPriority: number): string | undefined { 2 + switch (displayPriority) { 3 + case 1: 4 + return 'red'; 5 + case 2: 6 + return 'yellow'; 7 + case 3: 8 + return 'blue'; 9 + default: 10 + return undefined; 11 + } 12 + }