@nijaru/tk 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db/storage.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from "fs";
12
12
  import { join, resolve, basename } from "path";
13
13
  import { getTasksDir, getWorkingDir } from "../lib/root";
14
- import { isTaskOverdue } from "../lib/time";
14
+ import { isTaskOverdue, daysUntilDue } from "../lib/time";
15
15
  import type { Task, Config, Status, Priority, TaskWithMeta, LogEntry } from "../types";
16
16
  import { DEFAULT_CONFIG, taskId, parseId, generateRef } from "../types";
17
17
 
@@ -77,8 +77,8 @@ const STATUS_ORDER: Record<Status, number> = { active: 0, open: 1, done: 2 };
77
77
  * 3. If done: completed_at (newest first)
78
78
  */
79
79
  export function compareTasks(a: Task, b: Task): number {
80
- const sA = STATUS_ORDER[a.status] ?? 99;
81
- const sB = STATUS_ORDER[b.status] ?? 99;
80
+ const sA = STATUS_ORDER[a.status];
81
+ const sB = STATUS_ORDER[b.status];
82
82
  if (sA !== sB) return sA - sB;
83
83
 
84
84
  if (a.status !== "done") {
@@ -119,7 +119,12 @@ export function getConfig(): Config {
119
119
  }
120
120
  try {
121
121
  const text = readFileSync(configPath, "utf-8");
122
- return { ...DEFAULT_CONFIG, ...JSON.parse(text) };
122
+ const parsed = JSON.parse(text);
123
+ return {
124
+ ...DEFAULT_CONFIG,
125
+ ...parsed,
126
+ defaults: { ...DEFAULT_CONFIG.defaults, ...parsed.defaults },
127
+ };
123
128
  } catch {
124
129
  return { ...DEFAULT_CONFIG };
125
130
  }
@@ -158,7 +163,7 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
158
163
  // Find tasks to rename
159
164
  const toRename = allTasks.filter((t) => t.project === oldProject);
160
165
  if (toRename.length === 0) {
161
- throw new Error(`No tasks found with project "${oldProject}"`);
166
+ throw new Error(`No tasks found with project "${oldProject}". Run 'tk ls' to see projects.`);
162
167
  }
163
168
 
164
169
  // Check for collisions
@@ -166,7 +171,7 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
166
171
  for (const task of toRename) {
167
172
  const newId = `${newProject}-${task.ref}`;
168
173
  if (existingIds.has(newId)) {
169
- throw new Error(`Cannot rename: "${newId}" already exists`);
174
+ throw new Error(`Cannot rename: "${newId}" already exists. Choose a different project name.`);
170
175
  }
171
176
  }
172
177
 
@@ -234,18 +239,75 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
234
239
  };
235
240
  }
236
241
 
237
- export function setAlias(alias: string, path: string): Config {
238
- const config = getConfig();
239
- config.aliases[alias] = path;
240
- saveConfig(config);
241
- return config;
242
+ export interface MoveResult {
243
+ old_id: string;
244
+ new_id: string;
245
+ referencesUpdated: number;
242
246
  }
243
247
 
244
- export function removeAlias(alias: string): Config {
245
- const config = getConfig();
246
- delete config.aliases[alias];
247
- saveConfig(config);
248
- return config;
248
+ export function moveTask(oldId: string, newProject: string): MoveResult {
249
+ const tasksDir = getTasksDir();
250
+ if (!existsSync(tasksDir)) {
251
+ throw new Error("No .tasks/ directory found");
252
+ }
253
+
254
+ const parsed = parseId(oldId);
255
+ if (!parsed) {
256
+ throw new Error(`Invalid task ID: ${oldId}`);
257
+ }
258
+
259
+ if (parsed.project === newProject) {
260
+ throw new Error(`Task ${oldId} is already in project "${newProject}"`);
261
+ }
262
+
263
+ const oldPath = getTaskPath(tasksDir, oldId);
264
+ if (!existsSync(oldPath)) {
265
+ throw new Error(`Task not found: ${oldId}`);
266
+ }
267
+
268
+ const newId = `${newProject}-${parsed.ref}`;
269
+ const newPath = getTaskPath(tasksDir, newId);
270
+ if (existsSync(newPath)) {
271
+ throw new Error(
272
+ `Cannot move: "${newId}" already exists. The ref conflicts with an existing task.`,
273
+ );
274
+ }
275
+
276
+ const task = readTaskFile(oldPath, tasksDir);
277
+ if (!task) {
278
+ throw new Error(`Failed to read task: ${oldId}`);
279
+ }
280
+
281
+ // Write new file with updated project
282
+ task.project = newProject;
283
+ task.updated_at = new Date().toISOString();
284
+ atomicWrite(newPath, JSON.stringify(task, null, 2));
285
+ unlinkSync(oldPath);
286
+
287
+ // Update all blocked_by and parent refs in other tasks
288
+ let referencesUpdated = 0;
289
+ const allTasks = getAllTasks(tasksDir);
290
+ for (const other of allTasks) {
291
+ let modified = false;
292
+
293
+ if (other.blocked_by.includes(oldId)) {
294
+ other.blocked_by = other.blocked_by.map((id) => (id === oldId ? newId : id));
295
+ referencesUpdated++;
296
+ modified = true;
297
+ }
298
+
299
+ if (other.parent === oldId) {
300
+ other.parent = newId;
301
+ referencesUpdated++;
302
+ modified = true;
303
+ }
304
+
305
+ if (modified) {
306
+ atomicWrite(getTaskPath(tasksDir, taskId(other)), JSON.stringify(other, null, 2));
307
+ }
308
+ }
309
+
310
+ return { old_id: oldId, new_id: newId, referencesUpdated };
249
311
  }
250
312
 
251
313
  // --- Task File Operations ---
@@ -282,7 +344,8 @@ function isValidTaskStructure(obj: unknown): obj is Task {
282
344
  typeof t.ref === "string" &&
283
345
  typeof t.title === "string" &&
284
346
  typeof t.status === "string" &&
285
- Array.isArray(t.blocked_by)
347
+ Array.isArray(t.blocked_by) &&
348
+ Array.isArray(t.logs)
286
349
  );
287
350
  }
288
351
 
@@ -513,6 +576,7 @@ export function enrichTask(task: Task, statusMap?: Map<string, Status>): TaskWit
513
576
  id: taskId(task),
514
577
  is_overdue: isTaskOverdue(task.due_date, task.status),
515
578
  blocked_by_incomplete: blockedByIncomplete,
579
+ days_until_due: daysUntilDue(task.due_date, task.status),
516
580
  };
517
581
  }
518
582
 
@@ -594,9 +658,6 @@ export function getTask(id: string): TaskResult | null {
594
658
  };
595
659
  }
596
660
 
597
- // Deprecated alias for getTask
598
- export const getTaskWithMeta = getTask;
599
-
600
661
  export interface ListOptions {
601
662
  status?: Status;
602
663
  priority?: Priority;
@@ -685,7 +746,7 @@ export function updateTaskStatus(id: string, status: Status): (Task & { id: stri
685
746
 
686
747
  export interface UpdateTaskOptions {
687
748
  title?: string;
688
- description?: string;
749
+ description?: string | null;
689
750
  priority?: Priority;
690
751
  labels?: string[];
691
752
  assignees?: string[];
@@ -884,38 +945,13 @@ export function resolveId(input: string): string | null {
884
945
  const tasksDir = getTasksDir();
885
946
  if (!existsSync(tasksDir)) return null;
886
947
 
887
- // If it's already a full valid ID, check if task exists
888
- if (parseId(input)) {
889
- if (existsSync(getTaskPath(tasksDir, input))) {
890
- return input;
891
- }
892
- }
893
-
894
- const inputLower = input.toLowerCase();
895
-
896
- // Read filenames only (no file content parsing)
897
- const files = readdirSync(tasksDir);
898
- const matches: string[] = [];
899
-
900
- for (const file of files) {
901
- if (!file.endsWith(".json") || file === "config.json") continue;
902
-
903
- // Extract ID from filename: "project-ref.json" -> "project-ref"
904
- const id = file.slice(0, -5);
905
- if (!parseId(id)) continue;
906
-
907
- // Match by full ID prefix or just ref prefix
908
- const ref = id.split("-")[1] ?? "";
909
- if (id.startsWith(inputLower) || ref.startsWith(inputLower)) {
910
- matches.push(id);
911
- }
912
- }
913
-
914
- if (matches.length === 1 && matches[0]) {
915
- return matches[0];
948
+ // Fast path: full valid ID that exists
949
+ if (parseId(input) && existsSync(getTaskPath(tasksDir, input))) {
950
+ return input;
916
951
  }
917
952
 
918
- return null;
953
+ const matches = findMatchingIds(input);
954
+ return matches.length === 1 ? (matches[0] ?? null) : null;
919
955
  }
920
956
 
921
957
  export function findMatchingIds(input: string): string[] {
@@ -12,7 +12,7 @@ _tk() {
12
12
  local cur prev words cword
13
13
  _init_completion || return
14
14
 
15
- local commands="init add ls list ready show start done reopen edit log block unblock rm remove clean check config completions help"
15
+ local commands="init add ls list ready show start done reopen edit log block unblock mv move rm remove clean check config completions help"
16
16
  local global_opts="--json --help -h --version -V"
17
17
 
18
18
  # Find the command (first non-option word after 'tk')
@@ -60,6 +60,13 @@ _tk() {
60
60
  COMPREPLY=($(compgen -W "$(_tk_task_ids)" -- "$cur"))
61
61
  fi
62
62
  ;;
63
+ mv|move)
64
+ if [[ "$cur" == -* ]]; then
65
+ COMPREPLY=($(compgen -W "--json" -- "$cur"))
66
+ else
67
+ COMPREPLY=($(compgen -W "$(_tk_task_ids)" -- "$cur"))
68
+ fi
69
+ ;;
63
70
  edit)
64
71
  if [[ "$cur" == -* ]]; then
65
72
  COMPREPLY=($(compgen -W "-t --title -d --description -p --priority -l --labels -A --assignees --parent --estimate --due --json" -- "$cur"))
@@ -90,9 +97,7 @@ _tk() {
90
97
  ;;
91
98
  config)
92
99
  if [[ "$prev" == "config" ]]; then
93
- COMPREPLY=($(compgen -W "project alias --rm" -- "$cur"))
94
- elif [[ "$prev" == "alias" ]]; then
95
- COMPREPLY=($(compgen -W "--rm" -- "$cur"))
100
+ COMPREPLY=($(compgen -W "project" -- "$cur"))
96
101
  fi
97
102
  ;;
98
103
  init)
@@ -139,6 +144,8 @@ _tk() {
139
144
  'log:Add log entry'
140
145
  'block:Add blocker'
141
146
  'unblock:Remove blocker'
147
+ 'mv:Move task to different project'
148
+ 'move:Move task to different project'
142
149
  'rm:Delete task'
143
150
  'remove:Delete task'
144
151
  'clean:Remove old done tasks'
@@ -207,6 +214,12 @@ _tk() {
207
214
  '--json[Output as JSON]' \
208
215
  '1:task id:_tk_task_ids'
209
216
  ;;
217
+ mv|move)
218
+ _arguments \
219
+ '--json[Output as JSON]' \
220
+ '1:task id:_tk_task_ids' \
221
+ '2:project:'
222
+ ;;
210
223
  edit)
211
224
  _arguments \
212
225
  '(-t --title)'{-t,--title}'[Title]:title:' \
@@ -244,8 +257,7 @@ _tk() {
244
257
  ;;
245
258
  config)
246
259
  _arguments \
247
- '--rm[Remove alias]' \
248
- '1:subcommand:(project alias)'
260
+ '1:subcommand:(project)'
249
261
  ;;
250
262
  init)
251
263
  _arguments \
@@ -332,6 +344,8 @@ complete -c tk -n __tk_needs_command -f -a edit -d 'Edit task'
332
344
  complete -c tk -n __tk_needs_command -f -a log -d 'Add log entry'
333
345
  complete -c tk -n __tk_needs_command -f -a block -d 'Add blocker'
334
346
  complete -c tk -n __tk_needs_command -f -a unblock -d 'Remove blocker'
347
+ complete -c tk -n __tk_needs_command -f -a mv -d 'Move task to different project'
348
+ complete -c tk -n __tk_needs_command -f -a move -d 'Move task to different project'
335
349
  complete -c tk -n __tk_needs_command -f -a rm -d 'Delete task'
336
350
  complete -c tk -n __tk_needs_command -f -a remove -d 'Delete task'
337
351
  complete -c tk -n __tk_needs_command -f -a clean -d 'Remove old done tasks'
@@ -380,6 +394,8 @@ complete -c tk -n '__tk_using_command show' -f -a '(__tk_task_ids)' -d 'Task ID'
380
394
  complete -c tk -n '__tk_using_command start' -f -a '(__tk_task_ids)' -d 'Task ID'
381
395
  complete -c tk -n '__tk_using_command done' -f -a '(__tk_task_ids)' -d 'Task ID'
382
396
  complete -c tk -n '__tk_using_command reopen' -f -a '(__tk_task_ids)' -d 'Task ID'
397
+ complete -c tk -n '__tk_using_command mv' -f -a '(__tk_task_ids)' -d 'Task ID'
398
+ complete -c tk -n '__tk_using_command move' -f -a '(__tk_task_ids)' -d 'Task ID'
383
399
  complete -c tk -n '__tk_using_command rm' -f -a '(__tk_task_ids)' -d 'Task ID'
384
400
  complete -c tk -n '__tk_using_command remove' -f -a '(__tk_task_ids)' -d 'Task ID'
385
401
  complete -c tk -n '__tk_using_command log' -f -a '(__tk_task_ids)' -d 'Task ID'
@@ -414,8 +430,7 @@ complete -c tk -n '__tk_using_command clean' -l json -d 'Output as JSON'
414
430
  complete -c tk -n '__tk_using_command check' -l json -d 'Output as JSON'
415
431
 
416
432
  # config command
417
- complete -c tk -n '__tk_using_command config' -f -a 'project alias' -d 'Config option'
418
- complete -c tk -n '__tk_using_command config' -l rm -d 'Remove alias'
433
+ complete -c tk -n '__tk_using_command config' -f -a 'project' -d 'Config option'
419
434
 
420
435
  # init command
421
436
  complete -c tk -n '__tk_using_command init' -s P -l project -d 'Project'
@@ -33,7 +33,8 @@ describe("shouldUseColor", () => {
33
33
 
34
34
  test("respects NO_COLOR empty string as color enabled", () => {
35
35
  process.env.NO_COLOR = "";
36
- // When NO_COLOR is empty, color is allowed (depends on TTY)
36
+ // NO_COLOR="" is treated as unset color depends on TTY (always false in tests)
37
+ expect(shouldUseColor()).toBe(false);
37
38
  });
38
39
  });
39
40
 
@@ -59,6 +60,7 @@ describe("formatTaskRow", () => {
59
60
  external: {},
60
61
  blocked_by_incomplete: false,
61
62
  is_overdue: false,
63
+ days_until_due: null,
62
64
  };
63
65
 
64
66
  test("formats task without color", () => {
@@ -92,13 +94,13 @@ describe("formatTaskRow", () => {
92
94
  expect(parts.length).toBe(4); // ID | PRIO | STATUS | TITLE
93
95
  });
94
96
 
95
- test("truncates long project names to 6 chars", () => {
97
+ test("truncates long project names with ellipsis", () => {
96
98
  const longProjectTask: TaskWithMeta = {
97
99
  ...task,
98
100
  id: "mylongproject-a1b2",
99
101
  };
100
102
  const result = formatTaskRow(longProjectTask, false);
101
- expect(result).toContain("mylong-a1b2");
103
+ expect(result).toContain("mylon…-a1b2");
102
104
  expect(result).not.toContain("mylongproject");
103
105
  });
104
106
 
@@ -127,11 +129,81 @@ describe("formatTaskRow", () => {
127
129
  const result = formatTaskRow(todayTask, false);
128
130
  expect(result).not.toContain("[OVERDUE]");
129
131
  });
132
+
133
+ test("shows [due today] for tasks due today", () => {
134
+ const today = new Date().toISOString().split("T")[0] ?? null;
135
+ const todayTask: TaskWithMeta = {
136
+ ...task,
137
+ due_date: today,
138
+ is_overdue: false,
139
+ days_until_due: 0,
140
+ };
141
+ const result = formatTaskRow(todayTask, false);
142
+ expect(result).toContain("[due today]");
143
+ expect(result).not.toContain("[OVERDUE]");
144
+ });
145
+
146
+ test("shows [due Nd] for tasks due in N days within threshold", () => {
147
+ const future = new Date();
148
+ future.setDate(future.getDate() + 3);
149
+ const dateStr = future.toISOString().split("T")[0] ?? null;
150
+ const soonTask: TaskWithMeta = {
151
+ ...task,
152
+ due_date: dateStr,
153
+ is_overdue: false,
154
+ days_until_due: 3,
155
+ };
156
+ const result = formatTaskRow(soonTask, false);
157
+ expect(result).toContain("[due 3d]");
158
+ });
159
+
160
+ test("shows no due-soon marker at 8 days", () => {
161
+ const future = new Date();
162
+ future.setDate(future.getDate() + 8);
163
+ const dateStr = future.toISOString().split("T")[0] ?? null;
164
+ const notSoonTask: TaskWithMeta = {
165
+ ...task,
166
+ due_date: dateStr,
167
+ is_overdue: false,
168
+ days_until_due: 8,
169
+ };
170
+ const result = formatTaskRow(notSoonTask, false);
171
+ expect(result).not.toContain("[due");
172
+ expect(result).not.toContain("[OVERDUE]");
173
+ });
174
+
175
+ test("shows no due-soon marker when overdue", () => {
176
+ const overdueWithSoon: TaskWithMeta = {
177
+ ...task,
178
+ due_date: "2020-01-01",
179
+ is_overdue: true,
180
+ days_until_due: null,
181
+ };
182
+ const result = formatTaskRow(overdueWithSoon, false);
183
+ expect(result).toContain("[OVERDUE]");
184
+ expect(result).not.toContain("[due today]");
185
+ expect(result).not.toContain("[due ");
186
+ });
187
+
188
+ test("shows no due-soon marker for done tasks", () => {
189
+ const doneTask: TaskWithMeta = {
190
+ ...task,
191
+ status: "done",
192
+ days_until_due: null,
193
+ completed_at: new Date().toISOString(),
194
+ };
195
+ const result = formatTaskRow(doneTask, false);
196
+ expect(result).not.toContain("[due");
197
+ });
130
198
  });
131
199
 
132
200
  describe("formatTaskList", () => {
133
- test("returns message for empty list", () => {
134
- expect(formatTaskList([])).toBe("No tasks found.");
201
+ test("returns helpful message for empty list", () => {
202
+ expect(formatTaskList([])).toBe("No tasks found. Run 'tk add \"title\"' to create one.");
203
+ });
204
+
205
+ test("allows custom empty hint", () => {
206
+ expect(formatTaskList([], undefined, "Custom hint")).toBe("Custom hint");
135
207
  });
136
208
 
137
209
  test("includes header and divider", () => {
@@ -157,6 +229,7 @@ describe("formatTaskList", () => {
157
229
  external: {},
158
230
  blocked_by_incomplete: false,
159
231
  is_overdue: false,
232
+ days_until_due: null,
160
233
  },
161
234
  ];
162
235
  const result = formatTaskList(tasks, false);
@@ -190,6 +263,7 @@ describe("formatTaskList", () => {
190
263
  external: {},
191
264
  blocked_by_incomplete: false,
192
265
  is_overdue: false,
266
+ days_until_due: null,
193
267
  },
194
268
  {
195
269
  id: "tk-c3d4",
@@ -212,6 +286,7 @@ describe("formatTaskList", () => {
212
286
  external: {},
213
287
  blocked_by_incomplete: false,
214
288
  is_overdue: false,
289
+ days_until_due: null,
215
290
  },
216
291
  ];
217
292
  const result = formatTaskList(tasks, false);
@@ -244,6 +319,7 @@ describe("formatTaskDetail", () => {
244
319
  external: {},
245
320
  blocked_by_incomplete: true,
246
321
  is_overdue: false,
322
+ days_until_due: null,
247
323
  };
248
324
 
249
325
  test("includes all task fields", () => {
package/src/lib/format.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { TaskWithMeta, LogEntry } from "../types";
2
2
  import { PRIORITY_COLORS, STATUS_COLORS, OVERDUE_COLOR, RESET } from "../types";
3
3
  import { formatPriority } from "./priority";
4
- import { formatDate, formatRelativeTime } from "./time";
4
+ import { formatDate, formatRelativeTime, DUE_SOON_THRESHOLD } from "./time";
5
+
6
+ const DUE_SOON_COLOR = "\x1b[33m"; // yellow
5
7
 
6
8
  // Message colors
7
9
  const GREEN = "\x1b[32m";
@@ -10,17 +12,14 @@ const YELLOW = "\x1b[33m";
10
12
  const DIM = "\x1b[2m";
11
13
 
12
14
  /**
13
- * Determines if color output should be used.
15
+ * Determines if color output should be used for the given stream.
14
16
  * Respects NO_COLOR env var (https://no-color.org/) and TTY detection.
15
17
  */
16
- export function shouldUseColor(): boolean {
18
+ export function shouldUseColor(stream: NodeJS.WriteStream = process.stdout): boolean {
17
19
  if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
18
20
  return false;
19
21
  }
20
- if (!process.stdout.isTTY) {
21
- return false;
22
- }
23
- return true;
22
+ return stream.isTTY ?? false;
24
23
  }
25
24
 
26
25
  /** Format text green (success) */
@@ -28,9 +27,9 @@ export function green(msg: string): string {
28
27
  return shouldUseColor() ? `${GREEN}${msg}${RESET}` : msg;
29
28
  }
30
29
 
31
- /** Format text red (error) */
30
+ /** Format text red (error) — checks stderr since errors are written there */
32
31
  export function red(msg: string): string {
33
- return shouldUseColor() ? `${RED}${msg}${RESET}` : msg;
32
+ return shouldUseColor(process.stderr) ? `${RED}${msg}${RESET}` : msg;
34
33
  }
35
34
 
36
35
  /** Format text yellow (warning) */
@@ -43,23 +42,30 @@ export function dim(msg: string): string {
43
42
  return shouldUseColor() ? `${DIM}${msg}${RESET}` : msg;
44
43
  }
45
44
 
46
- function formatId(id: string): string {
47
- // Truncate project prefix to 6 chars, keep full 4-char ref
48
- // "myproject-a1b2" -> "myproj-a1b2"
45
+ function truncate(text: string, maxLen: number): string {
46
+ return text.length <= maxLen ? text : text.slice(0, maxLen - 1) + "…";
47
+ }
48
+
49
+ function formatId(id: string, maxLen = 11): string {
50
+ // Truncate project prefix to fit maxLen, keep full 4-char ref
51
+ // "myproject-a1b2" -> "mypr…-a1b2"
49
52
  const dash = id.lastIndexOf("-");
50
- if (dash === -1) return id.slice(0, 11);
53
+ if (dash === -1) return truncate(id, maxLen);
51
54
  const project = id.slice(0, dash);
52
55
  const ref = id.slice(dash + 1);
53
- const truncatedProject = project.length > 6 ? project.slice(0, 6) : project;
54
- return `${truncatedProject}-${ref}`;
56
+ const maxProjectLen = maxLen - ref.length - 1; // -1 for dash
57
+ return project.length > maxProjectLen
58
+ ? `${truncate(project, maxProjectLen)}-${ref}`
59
+ : `${project}-${ref}`;
55
60
  }
56
61
 
57
62
  export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
58
63
  const color = useColor ?? shouldUseColor();
59
64
  const id = formatId(task.id).padEnd(11);
60
65
  const priority = formatPriority(task.priority).padEnd(4);
61
- const title = task.title.slice(0, 50);
62
66
  const isOverdue = task.is_overdue;
67
+ const dueSoon =
68
+ !isOverdue && task.days_until_due !== null && task.days_until_due <= DUE_SOON_THRESHOLD;
63
69
 
64
70
  let statusText: string = task.status;
65
71
  if (task.status === "done" && task.completed_at) {
@@ -69,17 +75,29 @@ export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
69
75
 
70
76
  if (color) {
71
77
  const pc = PRIORITY_COLORS[task.priority];
72
- const sc = isOverdue ? OVERDUE_COLOR : STATUS_COLORS[task.status];
78
+ const sc = isOverdue ? OVERDUE_COLOR : dueSoon ? DUE_SOON_COLOR : STATUS_COLORS[task.status];
73
79
  const tc = task.status === "done" ? DIM : "";
80
+ const title = truncate(task.title, 50);
74
81
  return `${id} | ${pc}${priority}${RESET} | ${sc}${status}${RESET} | ${tc}${title}${RESET}`;
75
82
  }
76
83
 
84
+ let dueSoonMarker = "";
85
+ if (dueSoon) {
86
+ dueSoonMarker = task.days_until_due === 0 ? " [due today]" : ` [due ${task.days_until_due}d]`;
87
+ }
77
88
  const overdueMarker = isOverdue ? " [OVERDUE]" : "";
78
- return `${id} | ${priority} | ${status} | ${title}${overdueMarker}`;
89
+ const title = truncate(task.title, 50);
90
+ return `${id} | ${priority} | ${status} | ${title}${overdueMarker}${dueSoonMarker}`;
79
91
  }
80
92
 
81
- export function formatTaskList(tasks: TaskWithMeta[], useColor?: boolean): string {
82
- if (tasks.length === 0) return "No tasks found.";
93
+ export function formatTaskList(
94
+ tasks: TaskWithMeta[],
95
+ useColor?: boolean,
96
+ emptyHint?: string,
97
+ ): string {
98
+ if (tasks.length === 0) {
99
+ return emptyHint ?? "No tasks found. Run 'tk add \"title\"' to create one.";
100
+ }
83
101
  const color = useColor ?? shouldUseColor();
84
102
 
85
103
  const header = "ID | PRIO | STATUS | TITLE";
@@ -124,8 +142,16 @@ export function formatTaskDetail(task: TaskWithMeta, logs: LogEntry[], useColor?
124
142
  }
125
143
 
126
144
  if (task.due_date) {
145
+ const yc = color ? DUE_SOON_COLOR : "";
146
+ const dueSoon =
147
+ !task.is_overdue && task.days_until_due !== null && task.days_until_due <= DUE_SOON_THRESHOLD;
148
+ let dueSoonStr = "";
149
+ if (dueSoon) {
150
+ const marker = task.days_until_due === 0 ? "[due today]" : `[due ${task.days_until_due}d]`;
151
+ dueSoonStr = ` ${yc}${marker}${r}`;
152
+ }
127
153
  const overdueStr = task.is_overdue ? ` ${oc}[OVERDUE]${r}` : "";
128
- lines.push(`Due: ${task.due_date}${overdueStr}`);
154
+ lines.push(`Due: ${task.due_date}${overdueStr}${dueSoonStr}`);
129
155
  }
130
156
 
131
157
  lines.push(`Created: ${formatDate(task.created_at)}`);
@@ -155,22 +181,9 @@ export function formatJson(data: unknown): string {
155
181
  return JSON.stringify(data, null, 2);
156
182
  }
157
183
 
158
- export function formatConfig(config: {
159
- version: number;
160
- project: string;
161
- aliases: Record<string, string>;
162
- }): string {
184
+ export function formatConfig(config: { version: number; project: string }): string {
163
185
  const lines: string[] = [];
164
186
  lines.push(`Version: ${config.version}`);
165
187
  lines.push(`Project: ${config.project}`);
166
-
167
- if (Object.keys(config.aliases).length > 0) {
168
- lines.push("");
169
- lines.push("Aliases:");
170
- for (const [alias, path] of Object.entries(config.aliases)) {
171
- lines.push(` ${alias} → ${path}`);
172
- }
173
- }
174
-
175
188
  return lines.join("\n");
176
189
  }