pi-quests 0.3.0 → 0.5.0

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.
@@ -1,35 +1,138 @@
1
+ /**
2
+ * Error message design guidelines
3
+ *
4
+ * All error messages should follow a consistent three-part pattern:
5
+ * 1. PROBLEM — state what went wrong clearly and concisely
6
+ * 2. RULE — explain the constraint or invariant that was violated
7
+ * 3. RECOVERY HINT — suggest the next valid action the caller can take
8
+ *
9
+ * Example:
10
+ * "Quest [01] has incomplete steps. A parent quest can only be marked
11
+ * done after all its steps are complete. Try toggling the steps first."
12
+ *
13
+ * Keep messages actionable. Agents should never be left guessing why something
14
+ * failed or what to do next.
15
+ */
16
+
1
17
  export function formatQuestList(
2
- quests: { id: number; description: string; done: boolean }[],
18
+ quests: { id: string; description: string; done: boolean; parentId?: string }[],
3
19
  ): string {
4
20
  if (quests.length === 0) return "No quests.";
5
21
 
6
- return quests.map((q, i) => `#${i + 1} [${q.done ? "x" : " "}] ${q.description}`).join("\n");
22
+ const lines: string[] = [];
23
+ let pos = 1;
24
+ for (const q of quests) {
25
+ if (q.parentId) {
26
+ lines.push(` [${q.id}] [${q.done ? "x" : " "}] ${q.description}`);
27
+ } else {
28
+ lines.push(`#${pos} [${q.id}] [${q.done ? "x" : " "}] ${q.description}`);
29
+ pos++;
30
+ }
31
+ }
32
+ return lines.join("\n");
33
+ }
34
+
35
+ export function formatAddResult(q: { id: string; description: string }): string {
36
+ return `Added quest [${q.id}]: ${q.description}`;
37
+ }
38
+
39
+ export function formatBatchAddResult(added: { id: string; description: string }[]): string {
40
+ return `Added ${added.length} quests:\n${added.map((q) => `[${q.id}]: ${q.description}`).join("\n")}`;
41
+ }
42
+
43
+ export function formatToggleResult(id: string, done: boolean): string {
44
+ return `Quest [${id}] ${done ? "done" : "undone"}`;
45
+ }
46
+
47
+ export function formatUpdateResult(q: { id: string; description: string }): string {
48
+ return `Updated quest [${q.id}]: ${q.description}`;
49
+ }
50
+
51
+ export function formatDeleteResult(q: { id: string; description: string }): string {
52
+ return `Deleted quest [${q.id}]: ${q.description}`;
53
+ }
54
+
55
+ export function formatNotFound(id: string): string {
56
+ return `Quest [${id}] not found. IDs are random hex strings shown in brackets. Use the list action to see valid IDs.`;
57
+ }
58
+
59
+ export function formatBlockedBySteps(id: string): string {
60
+ return `Quest [${id}] has incomplete steps. A parent quest can only be marked done or deleted after all its steps are complete. Toggle the steps first, or delete them if they are no longer needed.`;
61
+ }
62
+
63
+ export function formatStepCannotHaveSteps(id: string): string {
64
+ return `Step [${id}] cannot have nested steps. The quest system only supports one level of nesting. Use a top-level quest as the parent instead.`;
65
+ }
66
+
67
+ export function formatEmptyDescriptionsError(): string {
68
+ return `Every description in a batch must be non-empty. Remove empty strings or split the batch into separate add calls.`;
7
69
  }
8
70
 
9
- export function formatAddResult(q: { id: number; description: string }): string {
10
- return `Added quest #${q.id}: ${q.description}`;
71
+ export function formatMissingDescriptionsError(action = "add"): string {
72
+ return `At least one description is required for the ${action} action. Provide a description string to create a quest.`;
11
73
  }
12
74
 
13
- export function formatBatchAddResult(added: { id: number; description: string }[]): string {
14
- return `Added ${added.length} quests:\n${added.map((q) => `#${q.id}: ${q.description}`).join("\n")}`;
75
+ export function formatIdRequiredError(action: string): string {
76
+ return `An id is required for the ${action} action. Use the list action to see valid quest IDs. IDs are the hex strings shown in square brackets.`;
77
+ }
78
+
79
+ export function formatDescriptionRequiredError(): string {
80
+ return `A description is required for the update action. Provide a non-empty string to update the quest.`;
81
+ }
82
+
83
+ export function formatTargetIdRequiredError(): string {
84
+ return `A targetId is required for the reorder action. Provide the hex ID of the quest to insert before.`;
85
+ }
86
+
87
+ export function formatReorderNotFoundError(): string {
88
+ return `Quest not found or is a step. Reorder only works on top-level quests. Use the list action to verify the ID and ensure it is a top-level quest.`;
89
+ }
90
+
91
+ export function formatParentNotFoundError(id: string): string {
92
+ return `Parent quest [${id}] not found. Use the list action to see valid parent IDs.`;
93
+ }
94
+
95
+ export function formatParentDoneError(id: string): string {
96
+ return `Cannot add a step to completed parent quest [${id}]. Steps can only be added to open parents. Reopen the parent first if you need to add more work.`;
97
+ }
98
+
99
+ export function formatUnknownActionError(action: string): string {
100
+ return `Unknown action: ${action}. Use the list action or check the tool schema for supported actions.`;
101
+ }
102
+
103
+ export function formatNothingToRevertError(): string {
104
+ return `Nothing to revert. The history is empty because no mutating actions have been performed yet.`;
105
+ }
106
+
107
+ export function formatReorderedQuestNotFoundError(): string {
108
+ return `Reordered quest not found. It may have been deleted or cleared since the reorder was recorded.`;
109
+ }
110
+
111
+ export function formatReparentResult(
112
+ q: { id: string; description: string },
113
+ parentId?: string,
114
+ ): string {
115
+ return parentId
116
+ ? `Moved quest [${q.id}] under [${parentId}]: ${q.description}`
117
+ : `Promoted quest [${q.id}] to top-level: ${q.description}`;
15
118
  }
16
119
 
17
- export function formatToggleResult(id: number, done: boolean): string {
18
- return `Quest #${id} ${done ? "done" : "undone"}`;
120
+ export function formatReparentTargetNotFoundError(id: string): string {
121
+ return `Target quest [${id}] not found. Use the list action to see valid parent IDs.`;
19
122
  }
20
123
 
21
- export function formatUpdateResult(q: { id: number; description: string }): string {
22
- return `Updated quest #${q.id}: ${q.description}`;
124
+ export function formatReparentTargetIsStepError(id: string): string {
125
+ return `Step [${id}] cannot be a parent. The quest system only supports one level of nesting. Use a top-level quest as the parent instead.`;
23
126
  }
24
127
 
25
- export function formatDeleteResult(q: { id: number; description: string }): string {
26
- return `Deleted quest #${q.id}: ${q.description}`;
128
+ export function formatReparentTargetDoneError(id: string): string {
129
+ return `Cannot reparent under completed parent [${id}]. Steps can only be added to open parents.`;
27
130
  }
28
131
 
29
- export function formatClearResult(count: number): string {
30
- return `Cleared ${count} quests`;
132
+ export function formatReparentDemoteHasStepsError(id: string): string {
133
+ return `Quest [${id}] has steps and cannot be demoted to a step. Delete or reparent its steps first.`;
31
134
  }
32
135
 
33
- export function formatNotFound(id: number): string {
34
- return `Quest #${id} not found`;
136
+ export function formatReparentSelfParentError(id: string): string {
137
+ return `Quest [${id}] cannot be its own parent. Choose a different parent ID.`;
35
138
  }
@@ -1,20 +1,8 @@
1
- export const COMPLEX_TASK_KEYWORDS = [
2
- "implement",
3
- "refactor",
4
- "investigate",
5
- "review",
6
- "analyze",
7
- "audit",
8
- "plan",
9
- "design",
10
- "create",
11
- "build",
12
- "write",
13
- "fix",
14
- ] as const;
15
-
16
- const ACKNOWLEDGEMENT =
17
- "ALWAYS acknowledge this reminder and create, update, or align on quests before making further tool calls.";
1
+ import type { ResolvedConfig } from "../config.js";
2
+
3
+ const ACKNOWLEDGEMENT = "Update your quest status before continuing.";
4
+
5
+ type NudgeCandidate = { index: number; message: string };
18
6
 
19
7
  export class QuestUsageTracker {
20
8
  private totalToolCalls = 0;
@@ -22,6 +10,10 @@ export class QuestUsageTracker {
22
10
  private hasEverUsedQuestTool = false;
23
11
  private lastQuestToolTime = 0;
24
12
  private nudgedThisTurn = false;
13
+ private lastNudgeTime = 0;
14
+ private lastNudgeIndex = -1;
15
+
16
+ constructor(private readonly config: ResolvedConfig) {}
25
17
 
26
18
  onToolExecution(toolName: string): void {
27
19
  this.totalToolCalls++;
@@ -38,50 +30,118 @@ export class QuestUsageTracker {
38
30
  this.nudgedThisTurn = false;
39
31
  }
40
32
 
41
- getNudge(activeQuestCount: number, latestPrompt?: string): string | undefined {
33
+ getNudge(
34
+ activeQuestCount: number,
35
+ latestPrompt?: string,
36
+ hasTopLevelQuestWithoutSubs?: boolean,
37
+ ): string | undefined {
42
38
  if (this.nudgedThisTurn) return undefined;
43
39
 
44
- // 1. Initialization nudge
45
- if (this.totalToolCalls >= 3 && !this.hasEverUsedQuestTool) {
40
+ const now = Date.now();
41
+ const cooldownMs = this.config.nudges.hintIntervalMinutes * 60 * 1000;
42
+ if (this.lastNudgeTime > 0 && now - this.lastNudgeTime < cooldownMs) {
43
+ return undefined;
44
+ }
45
+
46
+ const eligible = this.getEligibleNudges(
47
+ activeQuestCount,
48
+ latestPrompt,
49
+ hasTopLevelQuestWithoutSubs,
50
+ );
51
+
52
+ // Rotate priority: start checking from the nudge after the last one that fired
53
+ const rotated = [
54
+ ...eligible.filter((n) => n.index > this.lastNudgeIndex),
55
+ ...eligible.filter((n) => n.index <= this.lastNudgeIndex),
56
+ ];
57
+
58
+ const winner = rotated[0];
59
+ if (winner) {
46
60
  this.nudgedThisTurn = true;
47
- return `QUEST REMINDER: You have made ${this.totalToolCalls} tool calls but have NEVER used the quest tool this session. USE the quest tool to initialize tracking and break your work into concrete steps. ${ACKNOWLEDGEMENT}`;
61
+ this.lastNudgeTime = now;
62
+ this.lastNudgeIndex = winner.index;
63
+ return winner.message;
48
64
  }
49
65
 
50
- // 2. Complex-task entrypoint nudge
66
+ return undefined;
67
+ }
68
+
69
+ private getEligibleNudges(
70
+ activeQuestCount: number,
71
+ latestPrompt?: string,
72
+ hasTopLevelQuestWithoutSubs?: boolean,
73
+ ): NudgeCandidate[] {
74
+ const candidates: NudgeCandidate[] = [];
75
+
76
+ // 0. Initialization nudge
77
+ if (this.totalToolCalls >= this.config.nudges.toolCallThreshold && !this.hasEverUsedQuestTool) {
78
+ candidates.push({
79
+ index: 0,
80
+ message: `QUEST REMINDER: You have made ${this.totalToolCalls} tool calls but have NEVER used the quest tool this session. USE the quest tool to initialize tracking and break your work into concrete steps. ${ACKNOWLEDGEMENT}`,
81
+ });
82
+ }
83
+
84
+ // 1. Complex-task entrypoint nudge
51
85
  if (activeQuestCount === 0 && latestPrompt && this.isComplexPrompt(latestPrompt)) {
52
- this.nudgedThisTurn = true;
53
- return `QUEST REMINDER: Your latest prompt appears to be a complex task, but there are 0 active quests. USE the quest tool to break this into concrete, trackable steps. ${ACKNOWLEDGEMENT}`;
86
+ candidates.push({
87
+ index: 1,
88
+ message: `QUEST REMINDER: Your latest prompt appears to be a complex task, but there are 0 active quests. USE the quest tool to break this into concrete, trackable steps. ${ACKNOWLEDGEMENT}`,
89
+ });
54
90
  }
55
91
 
56
- // 3. Time-based alignment nudge
92
+ // 2. Time-based alignment nudge
57
93
  if (
58
94
  this.hasEverUsedQuestTool &&
59
95
  this.lastQuestToolTime > 0 &&
60
- this.consecutiveNonQuestToolCalls >= 3 &&
61
- // 8 minutes without quest tool use
62
- Date.now() - this.lastQuestToolTime >= 8 * 60 * 1000
96
+ this.consecutiveNonQuestToolCalls >= this.config.nudges.timeBasedToolCallThreshold &&
97
+ Date.now() - this.lastQuestToolTime >= this.config.nudges.hintIntervalMinutes * 60 * 1000
63
98
  ) {
64
- this.nudgedThisTurn = true;
65
- return `QUEST REMINDER: It has been a while since your last quest tool use and ${this.consecutiveNonQuestToolCalls} tools have been called since then. ALIGN on quest status before continuing. ${ACKNOWLEDGEMENT}`;
99
+ candidates.push({
100
+ index: 2,
101
+ message: `QUEST REMINDER: It has been a while since your last quest tool use and ${this.consecutiveNonQuestToolCalls} tools have been called since then. ALIGN on quest status before continuing. ${ACKNOWLEDGEMENT}`,
102
+ });
66
103
  }
67
104
 
68
- // 4. Zero-active sustained-work nudge
69
- if (this.consecutiveNonQuestToolCalls >= 5 && activeQuestCount === 0) {
70
- this.nudgedThisTurn = true;
71
- return `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool and there are 0 active quests. TRACK your work with specific, actionable quests. ${ACKNOWLEDGEMENT}`;
105
+ // 3. Zero-active sustained-work nudge
106
+ if (
107
+ this.consecutiveNonQuestToolCalls >= this.config.nudges.zeroActiveToolCallThreshold &&
108
+ activeQuestCount === 0
109
+ ) {
110
+ candidates.push({
111
+ index: 3,
112
+ message: `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool and there are 0 active quests. TRACK your work with specific, actionable quests. ${ACKNOWLEDGEMENT}`,
113
+ });
114
+ }
115
+
116
+ // 4. Sub-quest suggestion nudge
117
+ if (
118
+ this.hasEverUsedQuestTool &&
119
+ this.consecutiveNonQuestToolCalls >= this.config.nudges.stepSuggestionToolCallThreshold &&
120
+ activeQuestCount > 0 &&
121
+ hasTopLevelQuestWithoutSubs
122
+ ) {
123
+ candidates.push({
124
+ index: 4,
125
+ message: `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool and have active top-level quests without steps. Use the \`split\` action to break down complex tasks into smaller, trackable steps. ${ACKNOWLEDGEMENT}`,
126
+ });
72
127
  }
73
128
 
74
129
  // 5. Stale-progress sustained-work nudge
75
- if (this.consecutiveNonQuestToolCalls >= 10 && activeQuestCount > 0) {
76
- this.nudgedThisTurn = true;
77
- return `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool despite having active quests. UPDATE your quest progress to reflect current status. ${ACKNOWLEDGEMENT}`;
130
+ if (
131
+ this.consecutiveNonQuestToolCalls >= this.config.nudges.staleProgressToolCallThreshold &&
132
+ activeQuestCount > 0
133
+ ) {
134
+ candidates.push({
135
+ index: 5,
136
+ message: `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool despite having active quests.\nUPDATE your quest progress to reflect current status.\nALWAYS use steps to break down a quest into smaller steps, and to group related tasks together. ${ACKNOWLEDGEMENT}`,
137
+ });
78
138
  }
79
139
 
80
- return undefined;
140
+ return candidates;
81
141
  }
82
142
 
83
143
  private isComplexPrompt(prompt: string): boolean {
84
144
  const lower = prompt.toLowerCase();
85
- return COMPLEX_TASK_KEYWORDS.some((kw) => lower.includes(kw));
145
+ return this.config.nudges.complexTaskKeywords.some((kw) => lower.includes(kw));
86
146
  }
87
147
  }
@@ -11,6 +11,11 @@ export const QUEST_ACTIONS = {
11
11
  clear: "clear",
12
12
  reorder: "reorder",
13
13
  revert: "revert",
14
+ reparent: "reparent",
15
+ rules: "rules",
16
+ skill: "skill",
17
+ split: "split",
18
+ add_step: "add_step",
14
19
  } as const;
15
20
 
16
21
  export const QUEST_ACTION_VALUES = [
@@ -22,14 +27,22 @@ export const QUEST_ACTION_VALUES = [
22
27
  QUEST_ACTIONS.clear,
23
28
  QUEST_ACTIONS.reorder,
24
29
  QUEST_ACTIONS.revert,
30
+ QUEST_ACTIONS.reparent,
31
+ QUEST_ACTIONS.rules,
32
+ QUEST_ACTIONS.skill,
33
+ QUEST_ACTIONS.split,
34
+ QUEST_ACTIONS.add_step,
25
35
  ] as const;
26
36
 
27
37
  export type QuestActionType = (typeof QUEST_ACTION_VALUES)[number];
28
38
 
29
39
  export interface Quest {
30
- id: number;
40
+ id: string;
31
41
  description: string;
32
- additionalContext?: string;
33
42
  done: boolean;
34
43
  createdAt: number;
35
44
  }
45
+
46
+ export interface Step extends Quest {
47
+ parentId: string;
48
+ }
@@ -1,20 +1,22 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
+ import type { ResolvedConfig } from "../config.js";
3
4
  import { logger } from "../logger.js";
4
- import type { Quest } from "../quest/types.js";
5
+ import type { QuestLog } from "../quest/dataplane.js";
6
+ import { formatQuestRow, formatStepSpacerLine } from "./quests.js";
5
7
 
6
8
  export class QuestListWidget {
7
9
  private cachedWidth?: number;
8
10
  private cachedLines?: string[];
9
11
  private page = 0;
10
- private readonly pageSize = 10;
11
12
 
12
13
  constructor(
13
- private quests: Quest[],
14
+ private questLog: QuestLog,
14
15
  private theme: Theme,
15
16
  private onClose: () => void,
17
+ private readonly config: ResolvedConfig,
16
18
  ) {
17
- logger.debug("quests:widget", "create", { questCount: quests.length });
19
+ logger.debug("quests:widget", "create", { questCount: questLog.getAll().length });
18
20
  }
19
21
 
20
22
  handleInput(data: string): void {
@@ -37,7 +39,35 @@ export class QuestListWidget {
37
39
  }
38
40
 
39
41
  private get totalPages(): number {
40
- return Math.max(1, Math.ceil(this.quests.length / this.pageSize));
42
+ const totalLines = this.buildQuestLines(0).length;
43
+ return Math.max(1, Math.ceil(totalLines / this.config.display.pageSize));
44
+ }
45
+
46
+ private buildQuestLines(width: number): string[] {
47
+ const th = this.theme;
48
+ const parents = this.questLog.getQuests();
49
+ const lines: string[] = [];
50
+
51
+ for (let i = 0; i < parents.length; i++) {
52
+ const parent = parents[i];
53
+ const steps = this.questLog.getSteps(parent.id);
54
+
55
+ const row = formatQuestRow(th, parent, this.config.ids.length, i + 1);
56
+ lines.push(width > 0 ? truncateToWidth(row, width) : row);
57
+
58
+ if (steps.length > 0) {
59
+ lines.push(formatStepSpacerLine(th, this.config.ids.length));
60
+
61
+ for (const step of steps) {
62
+ const stepRow = formatQuestRow(th, step, this.config.ids.length);
63
+ lines.push(width > 0 ? truncateToWidth(stepRow, width) : stepRow);
64
+ }
65
+
66
+ lines.push("");
67
+ }
68
+ }
69
+
70
+ return lines;
41
71
  }
42
72
 
43
73
  private nextPage(): void {
@@ -72,13 +102,14 @@ export class QuestListWidget {
72
102
  width,
73
103
  page: this.page,
74
104
  totalPages: this.totalPages,
75
- totalQuests: this.quests.length,
105
+ totalQuests: this.questLog.getAll().length,
76
106
  });
77
107
 
78
108
  const th = this.theme;
79
109
  const lines: string[] = [];
80
- const total = this.quests.length;
81
- const doneCount = this.quests.filter((q) => q.done).length;
110
+ const allQuests = this.questLog.getAll();
111
+ const total = allQuests.length;
112
+ const doneCount = allQuests.filter((q) => q.done).length;
82
113
 
83
114
  // Top accent border
84
115
  lines.push(th.fg("accent", "─".repeat(width)));
@@ -91,7 +122,7 @@ export class QuestListWidget {
91
122
 
92
123
  lines.push("");
93
124
 
94
- if (this.quests.length === 0) {
125
+ if (total === 0) {
95
126
  lines.push(
96
127
  truncateToWidth(
97
128
  ` ${th.fg("dim", "No active quests. Add one with /quests add <description>")}`,
@@ -100,7 +131,7 @@ export class QuestListWidget {
100
131
  );
101
132
  } else {
102
133
  // Mini progress bar
103
- const barWidth = Math.min(width - 4, 24);
134
+ const barWidth = Math.min(width - 4, this.config.display.progressBarMaxWidth);
104
135
  const filled = Math.round((doneCount / total) * barWidth);
105
136
  const empty = barWidth - filled;
106
137
  const bar = th.fg("success", "█".repeat(filled)) + th.fg("dim", "░".repeat(empty));
@@ -108,20 +139,11 @@ export class QuestListWidget {
108
139
  lines.push(truncateToWidth(` ${bar} ${th.fg("muted", `${doneCount}/${total}`)}`, width));
109
140
  lines.push("");
110
141
 
111
- const start = this.page * this.pageSize;
112
- const pageQuests = this.quests.slice(start, start + this.pageSize);
113
- for (let i = 0; i < pageQuests.length; i++) {
114
- const q = pageQuests[i];
115
- const pos = start + i + 1;
116
- const marker = q.done ? th.fg("success", " ✓ ") : th.fg("muted", " ○ ");
117
- const idStr = th.fg(q.done ? "dim" : "accent", `#${pos}`);
118
- const desc = q.done
119
- ? th.fg("dim", th.strikethrough(q.description))
120
- : th.fg("text", q.description);
121
- const row = `${marker}${idStr} ${desc}`;
122
-
123
- lines.push(truncateToWidth(row, width));
124
- }
142
+ const questLines = this.buildQuestLines(width);
143
+ const start = this.page * this.config.display.pageSize;
144
+ const pageLines = questLines.slice(start, start + this.config.display.pageSize);
145
+
146
+ lines.push(...pageLines);
125
147
 
126
148
  if (this.totalPages > 1) {
127
149
  const pageInfo = ` Page ${this.page + 1}/${this.totalPages} ${th.fg("dim", "· Tab/Shift+Tab to navigate")}`;
@@ -0,0 +1,45 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { visibleWidth } from "@mariozechner/pi-tui";
3
+ import type { Quest, Step } from "../quest/types.js";
4
+
5
+ export function formatQuestRow(
6
+ theme: Theme,
7
+ q: Quest | Step,
8
+ idLength: number,
9
+ pos?: number,
10
+ ): string {
11
+ const isStep = "parentId" in q && q.parentId;
12
+
13
+ // position test is only relevant for quests, not steps
14
+ const posText = isStep ? "" : pos !== undefined ? `#${pos}` : "";
15
+ const posWidth = visibleWidth(posText);
16
+ const idText = `[${q.id}]`;
17
+
18
+ // spacing is added to keep text aligned
19
+ const spacing = " ".repeat(visibleWidth(`${16 ** idLength}`) - posWidth);
20
+ const idStr = `${theme.fg("muted", idText)} ${theme.fg(q.done ? "dim" : "accent", `${posText}`)}${spacing}`;
21
+
22
+ // for steps, use a different marker and indent
23
+ const markerNotDone = isStep ? theme.fg("muted", " └── ○ ") : theme.fg("muted", " ○ ");
24
+ const markerDone = isStep ? theme.fg("muted", " └── ✓ ") : theme.fg("success", " ✓ ");
25
+ const marker = q.done ? markerDone : markerNotDone;
26
+
27
+ // for steps, use dim text to contrast with the parent quests
28
+ const descColor = q.done || isStep ? "dim" : "text";
29
+ const desc = q.done
30
+ ? theme.fg(descColor, theme.strikethrough(q.description))
31
+ : theme.fg(descColor, q.description);
32
+
33
+ const line = ` ${idStr}${marker} ${desc}`;
34
+ return line;
35
+ }
36
+
37
+ export function formatStepSpacerLine(theme: Theme, idLength: number): string {
38
+ const idTextLength = visibleWidth(`${16 ** idLength}`);
39
+ // spacing for `[id](position string)`
40
+ const spacerStr = `${" ".repeat(idTextLength + 2)}${" ".repeat(idTextLength)}`;
41
+
42
+ const marker = theme.fg("muted", " │ ");
43
+
44
+ return ` ${spacerStr}${marker}`;
45
+ }