hold option to select multiple subtasks

Hunter
Saturday, March 7th, 2026 at 11:56 PM

index.html+503-52

index.html
 384  			let isF2Pressed = false;
 385  			let themes = [];
 386  
 387 +			// Multi-select state
 388 +			let multiSelectAnchorId = null  // task where multi-select started
 389 +			let multiSelectedIds = []       // ordered array of selected task ids
 390 +
 391 +			function clearMultiSelect() {
 392 +				multiSelectAnchorId = null
 393 +				multiSelectedIds = []
 394 +				// Remove .active from non-focused tasks
 395 +				const focused = document.activeElement
 396 +				const focusedContainer = focused ? focused.closest('.task-container') : null
 397 +				document.querySelectorAll('.task-container.active').forEach(el => {
 398 +					if (el !== focusedContainer) el.classList.remove('active')
 399 +				})
 400 +			}
 401 +
 402 +			function getMultiSelectedTasks() {
 403 +				// Return task objects in subtask order
 404 +				return currentTask.subtasks.filter(t => multiSelectedIds.includes(t.id))
 405 +			}
 406 +
 407 +			function applyMultiSelectHighlights() {
 408 +				for (const id of multiSelectedIds) {
 409 +					const container = document.querySelector(`.task-container[data-id="${id}"]`)
 410 +					if (container) container.classList.add('active')
 411 +				}
 412 +			}
 413 +
 414 +			function shakeAllSelected(direction = 'horizontal') {
 415 +				for (const id of multiSelectedIds) {
 416 +					applyShakeAnimation(id, direction)
 417 +				}
 418 +			}
 419 +
 420  			function handleSave(e) {
 421  				if ((e.metaKey || e.ctrlKey) && e.key === 's') {
 422  					e.preventDefault();

 609  							e.preventDefault();
 610  							return;
 611  						}
     -						if (taskInput.value === '' && keyHandler.backspace.canDelete) {
 612 +						// Multi-select backspace handling
 613 +						if (multiSelectedIds.length > 1) {
 614 +							const selected = getMultiSelectedTasks()
 615 +							const allEmpty = selected.every(t => t.text === '')
 616 +							if (allEmpty) {
 617 +								// All empty: require release+repress (canDelete) before bulk deleting
 618 +								e.preventDefault()
 619 +								if (keyHandler.backspace.canDelete) {
 620 +									keyHandler.backspace.blocked = true
 621 +									const toDelete = selected.filter(t => t !== taskPath[0] && t !== currentTask)
 622 +									// Don't delete if it would leave the root task with no subtasks
 623 +									if (toDelete.length > 0 && !(currentTask.id === 'root' && toDelete.length >= currentTask.subtasks.length)) {
 624 +										for (const t of toDelete) {
 625 +											const idx = currentTask.subtasks.findIndex(s => s.id === t.id)
 626 +											if (idx !== -1) currentTask.subtasks.splice(idx, 1)
 627 +										}
 628 +										clearMultiSelect()
 629 +										updateTaskAndAncestors(currentTask)
 630 +										if (currentTask.subtasks.length === 0 && taskPath.length > 1) {
 631 +											navigateToParentTask()
 632 +										} else {
 633 +											renderCurrentView()
 634 +										}
 635 +										scheduleSave()
 636 +									} else {
 637 +										// Can't delete: shake all selected tasks
 638 +										for (const t of selected) {
 639 +											applyShakeAnimation(t.id)
 640 +										}
 641 +									}
 642 +								}
 643 +								return
 644 +							}
 645 +							// Some tasks still have text: remove last char from all that have text
 646 +							e.preventDefault()
 647 +							for (const t of selected) {
 648 +								if (t.text.length > 0) {
 649 +									t.text = t.text.slice(0, -1)
 650 +									const inp = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`)
 651 +									if (inp) inp.value = t.text
 652 +								}
 653 +							}
 654 +							// Mark canDelete false so when all become empty, user must release+repress
 655 +							keyHandler.backspace.canDelete = false
 656 +							scheduleSave()
 657 +							return
 658 +						}
 659 +						if (taskInput.value === '' && keyHandler.backspace.canDelete && multiSelectedIds.length <= 1) {
 660  							e.preventDefault();
 661  							if (task !== taskPath[0]) {
 662  								keyHandler.backspace.blocked = true;
 663 +								clearMultiSelect()
 664  								if (task === currentTask) {
 665  									deleteCurrentParentTask();
 666  								} else {

 681  						if (keyHandler.enter.canAdd) {
 682  							e.preventDefault();
 683  							keyHandler.enter.blocked = true;
 684 +							clearMultiSelect()
 685  							addNewSubtask(currentTask, task);
 686  						}
     -					} else if (e.key === 'ArrowDown' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
 687 +					} else if (e.key === 'ArrowDown' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
 688  						if (keyHandler.arrowDown.blocked) {
 689  							e.preventDefault();
 690  							return;

 697  						} else {
 698  							handleKeyDown(e, task);
 699  						}
     -					} else if (e.key === 'ArrowDown' && e.shiftKey && !e.metaKey && !e.ctrlKey) {
 700 +					} else if (e.key === 'ArrowDown' && e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
 701  						if (keyHandler.shiftArrowDown.blocked) {
 702  							e.preventDefault();
 703  							return;
 704  						}
     -						if (isLastSubtask(task) && lastSubtaskShiftDownReleased && task !== currentTask && !e.repeat) {
 705 +						// Multi-select: insert new task above chunk when at bottom
 706 +						if (multiSelectedIds.length > 1 && !e.repeat) {
 707 +							const selected = getMultiSelectedTasks()
 708 +							const lastSelected = selected[selected.length - 1]
 709 +							if (isLastSubtask(lastSelected) && lastSubtaskShiftDownReleased) {
 710 +								e.preventDefault()
 711 +								keyHandler.shiftArrowDown.blocked = true
 712 +								// Find topmost selected task index and insert above chunk
 713 +								const topIndex = currentTask.subtasks.findIndex(t => multiSelectedIds.includes(t.id))
 714 +								const newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }
 715 +								currentTask.subtasks.splice(topIndex, 0, newSubtask)
 716 +								updateTaskAndAncestors(currentTask)
 717 +								currentTask.selectedSubtaskId = multiSelectAnchorId
 718 +								renderCurrentView()
 719 +								applyMultiSelectHighlights()
 720 +								scheduleSave()
 721 +								lastSubtaskShiftDownReleased = false
 722 +							} else {
 723 +								handleKeyDown(e, task)
 724 +							}
 725 +						} else if (isLastSubtask(task) && lastSubtaskShiftDownReleased && task !== currentTask && !e.repeat) {
 726  							e.preventDefault();
 727  							keyHandler.shiftArrowDown.blocked = true;
 728  							// Insert a new empty task above the current task, keeping current task selected

 757  						keyHandler.arrowDown.canAdd = true;
 758  						keyHandler.arrowDown.blocked = false;
 759  						keyHandler.shiftArrowDown.blocked = false;
     -						if (isLastSubtask(task)) {
 760 +						// During multi-select, check if bottommost selected task is last subtask
 761 +						const isAtBottom = multiSelectedIds.length > 1
 762 +							? isLastSubtask(getMultiSelectedTasks().slice(-1)[0])
 763 +							: isLastSubtask(task)
 764 +						if (isAtBottom) {
 765  							lastSubtaskDownArrowReleased = true;
 766  							lastSubtaskShiftDownReleased = true;
 767  						} else {

 778  				taskInput.addEventListener('keydown', keydownHandler);
 779  				taskInput.addEventListener('keyup', keyupHandler);
 780  				taskInput.addEventListener('keydown', handleCopyAndCut);
     -				taskInput.addEventListener('input', () => {
     -					task.text = taskInput.value;
 781 +				taskInput.addEventListener('input', (e) => {
 782 +					const oldText = task.text
 783 +					task.text = taskInput.value
 784 +					// Propagate edits to all other multi-selected tasks (always at end of text)
 785 +					if (multiSelectedIds.length > 1 && multiSelectedIds.includes(task.id)) {
 786 +						const otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id)
 787 +
 788 +						if (e.inputType === 'insertText' && e.data) {
 789 +							// Append typed character(s) to end of each task
 790 +							for (const t of otherSelected) {
 791 +								t.text = t.text + e.data
 792 +								const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`)
 793 +								if (otherInput) otherInput.value = t.text
 794 +							}
 795 +						} else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') {
 796 +							// Remove last character from each task
 797 +							for (const t of otherSelected) {
 798 +								if (t.text.length > 0) {
 799 +									t.text = t.text.slice(0, -1)
 800 +								}
 801 +								const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`)
 802 +								if (otherInput) otherInput.value = t.text
 803 +							}
 804 +						} else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') {
 805 +							// Compute what was inserted by comparing old and new text length
 806 +							const addedLen = taskInput.value.length - oldText.length
 807 +							if (addedLen > 0) {
 808 +								const pastedText = taskInput.value.slice(taskInput.selectionStart - addedLen, taskInput.selectionStart)
 809 +								for (const t of otherSelected) {
 810 +									t.text = t.text + pastedText
 811 +									const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`)
 812 +									if (otherInput) otherInput.value = t.text
 813 +								}
 814 +							}
 815 +						}
 816 +					}
 817  					if (task === currentTask) {
 818  						updatePageTitle(task);
 819  					}
 820  					scheduleSave();
 821  				});
 822  
     -				taskInput.addEventListener('focus', () => setActiveTask(taskInput, task));
 823 +				taskInput.addEventListener('focus', () => {
 824 +					// If clicking a task outside the multi-selection, clear it
 825 +					if (multiSelectedIds.length > 1 && !multiSelectedIds.includes(task.id)) {
 826 +						clearMultiSelect()
 827 +					}
 828 +					setActiveTask(taskInput, task)
 829 +				});
 830  
 831  				taskContainer.appendChild(checkbox);
 832  				taskContainer.appendChild(checkboxLabel);

 914  				const checkbox = document.querySelector(`.task-container[data-id="${taskId}"] .checkbox-label`);
 915  				if (checkbox) {
 916  					const className = direction === 'vertical' ? 'shake-vertical' : 'shake'
 917 +					if (checkbox.dataset.shaking) return
 918 +					checkbox.dataset.shaking = '1'
 919  					checkbox.classList.add(className);
     -					setTimeout(() => {
     -						checkbox.classList.remove(className);
     -					}, 250);
 920 +					checkbox.addEventListener('animationend', () => {
 921 +						checkbox.classList.remove(className)
 922 +					}, { once: true })
 923 +					const clearShaking = () => {
 924 +						delete checkbox.dataset.shaking
 925 +						document.removeEventListener('keyup', clearShaking)
 926 +					}
 927 +					document.addEventListener('keyup', clearShaking)
 928  				}
 929  			}
 930  

1064  				return null;
1065  			}
1066  
1067 +			// Multi-select: extend selection with Alt+Up/Down
1068 +			function extendMultiSelect(task, direction) {
1069 +				if (task === currentTask) return // can't multi-select the parent
1070 +
1071 +				const tasks = currentTask.subtasks
1072 +				if (tasks.length === 0) return
1073 +
1074 +				// Initialize multi-select from current task
1075 +				if (multiSelectAnchorId === null) {
1076 +					multiSelectAnchorId = task.id
1077 +					multiSelectedIds = [task.id]
1078 +				}
1079 +
1080 +				const anchorIndex = tasks.findIndex(t => t.id === multiSelectAnchorId)
1081 +				const currentIndex = tasks.findIndex(t => t.id === task.id)
1082 +
1083 +				if (direction === 'down') {
1084 +					// Find the bottommost selected task
1085 +					const bottomIndex = tasks.findIndex((t, i) => {
1086 +						return multiSelectedIds.includes(t.id) &&
1087 +							(i === tasks.length - 1 || !multiSelectedIds.includes(tasks[i + 1].id))
1088 +					})
1089 +					if (currentIndex <= anchorIndex && multiSelectedIds.length > 1) {
1090 +						// Contracting: remove from top
1091 +						multiSelectedIds = multiSelectedIds.filter(id => id !== task.id)
1092 +						const nextTask = tasks[currentIndex + 1]
1093 +						if (nextTask) selectAndFocusTask(nextTask)
1094 +					} else if (bottomIndex < tasks.length - 1) {
1095 +						// Extending downward
1096 +						const nextTask = tasks[bottomIndex + 1]
1097 +						multiSelectedIds.push(nextTask.id)
1098 +						selectAndFocusTask(nextTask)
1099 +					}
1100 +				} else {
1101 +					// Find the topmost selected task
1102 +					const topIndex = tasks.findIndex(t => multiSelectedIds.includes(t.id))
1103 +					if (currentIndex >= anchorIndex && multiSelectedIds.length > 1) {
1104 +						// Contracting: remove from bottom
1105 +						multiSelectedIds = multiSelectedIds.filter(id => id !== task.id)
1106 +						const prevTask = tasks[currentIndex - 1]
1107 +						if (prevTask) selectAndFocusTask(prevTask)
1108 +					} else if (topIndex > 0) {
1109 +						// Extending upward
1110 +						const prevTask = tasks[topIndex - 1]
1111 +						multiSelectedIds.unshift(prevTask.id)
1112 +						selectAndFocusTask(prevTask)
1113 +					}
1114 +				}
1115 +
1116 +				applyMultiSelectHighlights()
1117 +			}
1118 +
1119  			function handleKeyDown(e, task) {
1120  				const cmd = e.metaKey || e.ctrlKey;
1121 +				const hasMultiSelect = multiSelectedIds.length > 1
1122  
1123 +				// Alt+Up/Down: extend multi-selection
1124 +				if (e.key === 'ArrowUp' && e.altKey && !cmd && !e.shiftKey) {
1125 +					e.preventDefault();
1126 +					extendMultiSelect(task, 'up')
1127 +					return
1128 +				} else if (e.key === 'ArrowDown' && e.altKey && !cmd && !e.shiftKey) {
1129 +					e.preventDefault();
1130 +					extendMultiSelect(task, 'down')
1131 +					return
1132 +				}
1133 +
1134 +				// Shift+Enter: toggle task state (bulk if multi-selected)
1135  				if (e.key === 'Enter' && e.shiftKey) {
1136  					e.preventDefault();
1137  					if (!keyHandler.shiftEnter.pressed) {
1138  						keyHandler.shiftEnter.pressed = true;
     -						toggleTaskState(task);
1139 +						if (hasMultiSelect) {
1140 +							bulkToggleTaskState()
1141 +						} else {
1142 +							toggleTaskState(task);
1143 +						}
1144  					}
     -				} else if (e.key === 'ArrowUp' && cmd && !e.shiftKey) {
1145 +				// Cmd+Up: push into task above (bulk if multi-selected)
1146 +				} else if (e.key === 'ArrowUp' && cmd && !e.shiftKey && !e.altKey) {
1147  					e.preventDefault();
     -					if (!e.repeat && !pushSubtaskIntoTarget(task, 'up')) {
     -						applyShakeAnimation(task.id, 'vertical');
1148 +					if (!e.repeat) {
1149 +						const success = hasMultiSelect ? pushMultiSelectedIntoTarget('up') : pushSubtaskIntoTarget(task, 'up')
1150 +						if (!success) {
1151 +							hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical')
1152 +						}
1153  					}
     -				} else if (e.key === 'ArrowDown' && cmd && !e.shiftKey) {
1154 +				// Cmd+Down: push into task below (bulk if multi-selected)
1155 +				} else if (e.key === 'ArrowDown' && cmd && !e.shiftKey && !e.altKey) {
1156  					e.preventDefault();
     -					if (!e.repeat && !pushSubtaskIntoTarget(task, 'down')) {
     -						applyShakeAnimation(task.id, 'vertical');
1157 +					if (!e.repeat) {
1158 +						const success = hasMultiSelect ? pushMultiSelectedIntoTarget('down') : pushSubtaskIntoTarget(task, 'down')
1159 +						if (!success) {
1160 +							hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical')
1161 +						}
1162  					}
     -				} else if (e.key === 'ArrowRight' && cmd && !e.shiftKey) {
1163 +				} else if (e.key === 'ArrowRight' && cmd && !e.shiftKey && !e.altKey) {
1164  					e.preventDefault();
     -					applyShakeAnimation(task.id);
     -				} else if (e.key === 'ArrowLeft' && cmd && !e.shiftKey) {
1165 +					hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id);
1166 +				// Cmd+Left: pull out one level (bulk if multi-selected)
1167 +				} else if (e.key === 'ArrowLeft' && cmd && !e.shiftKey && !e.altKey) {
1168  					e.preventDefault();
     -					if (!e.repeat && !pullSubtaskOutLayer(task)) {
     -						applyShakeAnimation(task.id);
1169 +					if (!e.repeat) {
1170 +						const success = hasMultiSelect ? pullMultiSelectedOutLayer() : pullSubtaskOutLayer(task)
1171 +						if (!success) {
1172 +							hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id)
1173 +						}
1174  					}
     -				} else if (e.key === 'ArrowUp' && cmd && e.shiftKey) {
1175 +				// Cmd+Shift+Up: push and navigate (bulk if multi-selected)
1176 +				} else if (e.key === 'ArrowUp' && cmd && e.shiftKey && !e.altKey) {
1177  					e.preventDefault();
     -					if (!e.repeat && !pushSubtaskIntoTarget(task, 'up', true)) {
     -						applyShakeAnimation(task.id, 'vertical');
1178 +					if (!e.repeat) {
1179 +						const success = hasMultiSelect ? pushMultiSelectedIntoTarget('up', true) : pushSubtaskIntoTarget(task, 'up', true)
1180 +						if (!success) {
1181 +							hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical')
1182 +						}
1183  					}
     -				} else if (e.key === 'ArrowDown' && cmd && e.shiftKey) {
1184 +				// Cmd+Shift+Down: push and navigate (bulk if multi-selected)
1185 +				} else if (e.key === 'ArrowDown' && cmd && e.shiftKey && !e.altKey) {
1186  					e.preventDefault();
     -					if (!e.repeat && !pushSubtaskIntoTarget(task, 'down', true)) {
     -						applyShakeAnimation(task.id, 'vertical');
1187 +					if (!e.repeat) {
1188 +						const success = hasMultiSelect ? pushMultiSelectedIntoTarget('down', true) : pushSubtaskIntoTarget(task, 'down', true)
1189 +						if (!success) {
1190 +							hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical')
1191 +						}
1192  					}
     -				} else if (e.key === 'ArrowLeft' && cmd && e.shiftKey) {
1193 +				// Cmd+Shift+Left: pull and navigate (bulk if multi-selected)
1194 +				} else if (e.key === 'ArrowLeft' && cmd && e.shiftKey && !e.altKey) {
1195  					e.preventDefault();
     -					if (!e.repeat && !pullSubtaskOutLayer(task, true)) {
     -						applyShakeAnimation(task.id);
1196 +					if (!e.repeat) {
1197 +						const success = hasMultiSelect ? pullMultiSelectedOutLayer(true) : pullSubtaskOutLayer(task, true)
1198 +						if (!success) {
1199 +							hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id)
1200 +						}
1201  					}
     -				} else if (e.key === 'ArrowRight' && cmd && e.shiftKey) {
1202 +				} else if (e.key === 'ArrowRight' && cmd && e.shiftKey && !e.altKey) {
1203  					e.preventDefault();
     -					applyShakeAnimation(task.id);
     -				} else if (e.key === 'ArrowUp' && !e.shiftKey) {
1204 +					hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id);
1205 +				// Plain Up/Down: navigate (clears multi-select)
1206 +				} else if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
1207  					e.preventDefault();
1208 +					if (hasMultiSelect) clearMultiSelect()
1209  					navigateTasks('up');
     -				} else if (e.key === 'ArrowDown' && !e.shiftKey) {
1210 +				} else if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
1211  					e.preventDefault();
1212 +					if (hasMultiSelect) clearMultiSelect()
1213  					navigateTasks('down');
     -				} else if (e.key === 'ArrowUp' && e.shiftKey) {
1214 +				// Shift+Up/Down: move task (bulk if multi-selected)
1215 +				} else if (e.key === 'ArrowUp' && e.shiftKey && !e.altKey) {
1216  					e.preventDefault();
     -					if (!moveSubtask(task, 'up')) {
     -						applyShakeAnimation(task.id, 'vertical');
1217 +					if (hasMultiSelect) {
1218 +						if (!moveMultiSelected('up')) shakeAllSelected('vertical')
1219 +					} else {
1220 +						if (!moveSubtask(task, 'up')) applyShakeAnimation(task.id, 'vertical')
1221  					}
     -				} else if (e.key === 'ArrowDown' && e.shiftKey) {
1222 +				} else if (e.key === 'ArrowDown' && e.shiftKey && !e.altKey) {
1223  					e.preventDefault();
     -					if (!moveSubtask(task, 'down')) {
     -						applyShakeAnimation(task.id, 'vertical');
1224 +					if (hasMultiSelect) {
1225 +						if (!moveMultiSelected('down')) shakeAllSelected('vertical')
1226 +					} else {
1227 +						if (!moveSubtask(task, 'down')) applyShakeAnimation(task.id, 'vertical')
1228  					}
     -				} else if (e.key === 'ArrowRight' && e.shiftKey) {
1229 +				// Shift+Right: blocked during multi-select, shake all
1230 +				} else if (e.key === 'ArrowRight' && e.shiftKey && !e.altKey) {
1231  					e.preventDefault();
     -					if (!keyHandler.shiftRight.pressed) {
1232 +					if (hasMultiSelect) {
1233 +						if (!keyHandler.shiftRight.pressed) {
1234 +							keyHandler.shiftRight.pressed = true
1235 +							shakeAllSelected()
1236 +						}
1237 +					} else if (!keyHandler.shiftRight.pressed) {
1238  						keyHandler.shiftRight.pressed = true;
1239  						if (task !== currentTask) {
1240  							navigateIntoSubtask(task);

1242  							applyShakeAnimation(task.id);
1243  						}
1244  					}
     -				} else if (e.key === 'ArrowLeft' && e.shiftKey) {
1245 +				// Shift+Left: during multi-select, shake at root or clear and navigate out
1246 +				} else if (e.key === 'ArrowLeft' && e.shiftKey && !e.altKey) {
1247  					e.preventDefault();
     -					if (!keyHandler.shiftLeft.pressed) {
1248 +					if (hasMultiSelect && currentTask.id === 'root') {
1249 +						if (!keyHandler.shiftLeft.pressed) {
1250 +							keyHandler.shiftLeft.pressed = true
1251 +							shakeAllSelected()
1252 +						}
1253 +					} else if (hasMultiSelect) {
1254 +						clearMultiSelect()
1255 +						if (!keyHandler.shiftLeft.pressed) {
1256 +							keyHandler.shiftLeft.pressed = true
1257 +							navigateToParentTask()
1258 +						}
1259 +					} else if (!keyHandler.shiftLeft.pressed) {
1260  						keyHandler.shiftLeft.pressed = true;
1261  						navigateToParentTask();
1262  					}

1396  				return true;
1397  			}
1398  
1399 +			// Bulk move: shift entire multi-selected chunk up or down
1400 +			function moveMultiSelected(direction) {
1401 +				const selected = getMultiSelectedTasks()
1402 +				if (selected.length === 0) return false
1403 +
1404 +				const subtasks = currentTask.subtasks
1405 +				const indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b)
1406 +				const topIndex = indices[0]
1407 +				const bottomIndex = indices[indices.length - 1]
1408 +
1409 +				if (direction === 'up' && topIndex <= 0) return false
1410 +				if (direction === 'down' && bottomIndex >= subtasks.length - 1) return false
1411 +
1412 +				// Remove the chunk
1413 +				const chunk = subtasks.splice(topIndex, selected.length)
1414 +				// Re-insert shifted by one
1415 +				const insertAt = direction === 'up' ? topIndex - 1 : topIndex + 1
1416 +				subtasks.splice(insertAt, 0, ...chunk)
1417 +
1418 +				// Ensure render focuses a multi-selected task
1419 +				currentTask.selectedSubtaskId = multiSelectAnchorId
1420 +				renderCurrentView()
1421 +				applyMultiSelectHighlights()
1422 +				scheduleSave()
1423 +				return true
1424 +			}
1425 +
1426 +			// Bulk push: push entire multi-selected chunk into adjacent task
1427 +			function pushMultiSelectedIntoTarget(direction, navigate = false) {
1428 +				const selected = getMultiSelectedTasks()
1429 +				if (selected.length === 0) return false
1430 +
1431 +				if (navigate) {
1432 +					document.documentElement.style.scrollBehavior = 'auto'
1433 +				}
1434 +
1435 +				const subtasks = currentTask.subtasks
1436 +				const indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b)
1437 +				const topIndex = indices[0]
1438 +				const bottomIndex = indices[indices.length - 1]
1439 +
1440 +				// Find target: the task adjacent to the chunk that is NOT in the selection
1441 +				let targetTask = null
1442 +				if (direction === 'up' && topIndex > 0) {
1443 +					targetTask = subtasks[topIndex - 1]
1444 +				} else if (direction === 'down' && bottomIndex < subtasks.length - 1) {
1445 +					targetTask = subtasks[bottomIndex + 1]
1446 +				}
1447 +				if (!targetTask) return false
1448 +
1449 +				// Remove selected tasks from current parent, keeping target
1450 +				const chunk = selected.slice()
1451 +				currentTask.subtasks = currentTask.subtasks.filter(t => !multiSelectedIds.includes(t.id))
1452 +
1453 +				// Add chunk to target's subtasks (at the top, preserving order)
1454 +				targetTask.subtasks.unshift(...chunk)
1455 +
1456 +				// Adjust states for moved tasks
1457 +				for (const t of chunk) {
1458 +					adjustMovedTaskState(t, targetTask)
1459 +				}
1460 +				targetTask.selectedSubtaskId = multiSelectAnchorId
1461 +				updateTaskAndAncestors(currentTask)
1462 +				updateTaskAndAncestors(targetTask)
1463 +
1464 +				if (navigate) {
1465 +					// Navigate into target, keep multi-select
1466 +					currentTask.selectedSubtaskId = targetTask.id
1467 +					taskPath.push(targetTask)
1468 +					currentTask = targetTask
1469 +					updateBreadcrumbs(currentTask)
1470 +					renderCurrentView()
1471 +					applyMultiSelectHighlights()
1472 +				} else {
1473 +					// Stay at current level, chunk is no longer visible
1474 +					clearMultiSelect()
1475 +					renderCurrentView()
1476 +					selectAndFocusTask(targetTask)
1477 +				}
1478 +
1479 +				scheduleSave()
1480 +				return true
1481 +			}
1482 +
1483 +			// Bulk pull: pull entire multi-selected chunk out one level
1484 +			function pullMultiSelectedOutLayer(navigate = false) {
1485 +				if (taskPath.length <= 1) return false
1486 +
1487 +				const selected = getMultiSelectedTasks()
1488 +				if (selected.length === 0) return false
1489 +
1490 +				if (navigate) {
1491 +					document.documentElement.style.scrollBehavior = 'auto'
1492 +				}
1493 +
1494 +				const currentParent = currentTask
1495 +				const grandParent = taskPath[taskPath.length - 2]
1496 +				if (!grandParent) return false
1497 +
1498 +				// Remove chunk from current parent
1499 +				currentParent.subtasks = currentParent.subtasks.filter(t => !multiSelectedIds.includes(t.id))
1500 +
1501 +				// Insert after current parent in grandparent's subtasks, preserving order
1502 +				const parentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id)
1503 +				if (parentIndex === -1) {
1504 +					grandParent.subtasks.push(...selected)
1505 +				} else {
1506 +					grandParent.subtasks.splice(parentIndex + 1, 0, ...selected)
1507 +				}
1508 +
1509 +				// Adjust states for moved tasks
1510 +				for (const t of selected) {
1511 +					adjustMovedTaskState(t, grandParent)
1512 +				}
1513 +
1514 +				updateTaskAndAncestors(currentParent)
1515 +				updateTaskAndAncestors(grandParent)
1516 +
1517 +				if (navigate || currentParent.subtasks.length === 0) {
1518 +					// Navigate out to parent level, keep multi-select
1519 +					taskPath.pop()
1520 +					currentTask = taskPath[taskPath.length - 1]
1521 +					// Set selectedSubtaskId to anchor so renderCurrentView focuses a multi-selected task
1522 +					currentTask.selectedSubtaskId = multiSelectAnchorId
1523 +					updateBreadcrumbs(currentTask)
1524 +					renderCurrentView()
1525 +					applyMultiSelectHighlights()
1526 +				} else {
1527 +					// Stay at current level, chunk is gone
1528 +					clearMultiSelect()
1529 +					renderCurrentView()
1530 +					if (currentParent.subtasks.length > 0) {
1531 +						selectAndFocusTask(currentParent.subtasks[0])
1532 +					} else {
1533 +						selectAndFocusTask(currentParent)
1534 +					}
1535 +				}
1536 +
1537 +				scheduleSave()
1538 +				return true
1539 +			}
1540 +
1541 +			// Bulk toggle: uniform toggle for multi-selected tasks
1542 +			function bulkToggleTaskState() {
1543 +				const selected = getMultiSelectedTasks()
1544 +				if (selected.length === 0) return
1545 +
1546 +				// If any are unchecked (state !== 1), check all. Otherwise uncheck all.
1547 +				const anyUnchecked = selected.some(t => t.state !== 1)
1548 +				const newState = anyUnchecked ? 1 : 0
1549 +
1550 +				for (const t of selected) {
1551 +					t.state = newState
1552 +					// Update subtask states recursively
1553 +					function setAllSubtasks(task, state) {
1554 +						task.state = state
1555 +						for (const sub of task.subtasks) {
1556 +							setAllSubtasks(sub, state)
1557 +						}
1558 +					}
1559 +					setAllSubtasks(t, newState)
1560 +				}
1561 +
1562 +				updateTaskAndAncestors(currentTask)
1563 +				currentTask.selectedSubtaskId = multiSelectAnchorId
1564 +				renderCurrentView()
1565 +				applyMultiSelectHighlights()
1566 +				scheduleSave()
1567 +			}
1568  
1569  			function addNewSubtask(parentTask, currentSubtask = null) {
1570  				const newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null };

1728  				if (task !== currentTask) {
1729  					currentTask.selectedSubtaskId = task.id;
1730  				}
1731 +				// Re-apply multi-select highlights after clearing
1732 +				if (multiSelectedIds.length > 1) {
1733 +					applyMultiSelectHighlights()
1734 +				}
1735  				updateBreadcrumbs(task);
     -				lastSubtaskDownArrowReleased = isLastSubtask(task);
     -				lastSubtaskShiftDownReleased = isLastSubtask(task);
1736 +				// During multi-select, check bottommost selected task for "last subtask" status
1737 +				const bottomTask = multiSelectedIds.length > 1
1738 +					? getMultiSelectedTasks().slice(-1)[0]
1739 +					: task
1740 +				lastSubtaskDownArrowReleased = isLastSubtask(bottomTask);
1741 +				lastSubtaskShiftDownReleased = isLastSubtask(bottomTask);
1742  				input.focus();
     -				
1743 +
1744  				// Center the active task in the viewport
     -				const activeTaskElement = document.querySelector('.task-container.active');
1745 +				const activeTaskElement = input.closest('.task-container');
1746  				if (activeTaskElement && activeTaskElement.parentElement && activeTaskElement.parentElement.tagName === 'LI') {
1747  					// Use scrollIntoView with block: "center" to center the element vertically
1748  					activeTaskElement.scrollIntoView({

1750  						block: 'center',   // Center vertically
1751  						inline: 'nearest'  // Don't change horizontal scroll
1752  					});
     -					
1753 +
1754  					// Re-enable smooth scrolling after all DOM updates and scrolling are complete
1755  					setTimeout(() => {
1756  						document.documentElement.style.scrollBehavior = 'smooth';

1760  
1761  			function updateBreadcrumbs(selectedTask) {
1762  				const breadcrumbsContainer = document.getElementById('breadcrumbs');
     -				const trail = generateBreadcrumbs(taskPath[0], taskPath, selectedTask.id);
1763 +				// During multi-select, show breadcrumbs as if parent is selected (no deeper traversal)
1764 +				const effectiveId = multiSelectedIds.length > 1 ? currentTask.id : selectedTask.id;
1765 +				const trail = generateBreadcrumbs(taskPath[0], taskPath, effectiveId);
1766  				breadcrumbsContainer.textContent = trail;
1767  			}
1768