pi-quests 0.1.0 → 0.3.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.
@@ -0,0 +1,430 @@
1
+ import type { AgentToolResult, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { logger } from "../logger.js";
3
+ import {
4
+ formatBatchAddResult,
5
+ formatDeleteResult,
6
+ formatNotFound,
7
+ formatQuestList,
8
+ formatToggleResult,
9
+ formatUpdateResult,
10
+ } from "./formatters.js";
11
+ import { QUEST_ACTIONS, type Quest } from "./types.js";
12
+
13
+ export type HistoryEntry =
14
+ | { type: typeof QUEST_ACTIONS.add; id: number }
15
+ | { type: typeof QUEST_ACTIONS.toggle; id: number }
16
+ | { type: typeof QUEST_ACTIONS.update; id: number; previousDescription: string }
17
+ | { type: typeof QUEST_ACTIONS.delete; quest: Quest; index: number }
18
+ | {
19
+ type: typeof QUEST_ACTIONS.clear;
20
+ previousQuests: Quest[];
21
+ previousNextId: number;
22
+ all: false;
23
+ }
24
+ | { type: typeof QUEST_ACTIONS.clear; quests: Quest[]; nextId: number; all: true }
25
+ | { type: typeof QUEST_ACTIONS.reorder; quest: Quest; oldIndex: number; previousIds: number[] };
26
+
27
+ export type QuestAction =
28
+ | { type: typeof QUEST_ACTIONS.add; descriptions?: string[] }
29
+ | { type: typeof QUEST_ACTIONS.list }
30
+ | { type: typeof QUEST_ACTIONS.toggle; id?: number }
31
+ | { type: typeof QUEST_ACTIONS.update; id?: number; description?: string }
32
+ | { type: typeof QUEST_ACTIONS.delete; id?: number }
33
+ | { type: typeof QUEST_ACTIONS.clear; all?: boolean }
34
+ | { type: typeof QUEST_ACTIONS.reorder; id?: number; targetIndex?: number }
35
+ | { type: typeof QUEST_ACTIONS.revert };
36
+
37
+ export type QuestOperationResult = { success: boolean; message: string; quest?: Quest };
38
+
39
+ /**
40
+ * In-memory quest data plane.
41
+ *
42
+ * Holds quest state, mutation history, and exposes `execute()` as the
43
+ * single entry point for all adapter layers (commands, tools, etc.).
44
+ */
45
+ export class QuestLog {
46
+ private quests: Quest[] = [];
47
+ private nextId = 1;
48
+ private history: HistoryEntry[] = [];
49
+ private undoHandlers: {
50
+ [K in HistoryEntry["type"]]: (entry: Extract<HistoryEntry, { type: K }>) => {
51
+ success: boolean;
52
+ message: string;
53
+ };
54
+ } = {
55
+ [QUEST_ACTIONS.add]: (entry) => {
56
+ this.quests = this.quests.filter((q) => q.id !== entry.id);
57
+
58
+ const maxId = this.quests.reduce((max, q) => Math.max(max, q.id), 0);
59
+ this.nextId = Math.max(maxId + 1, entry.id);
60
+
61
+ logger.debug("quests:state", "revert-add", { id: entry.id, total: this.quests.length });
62
+ return { success: true, message: `Reverted add quest #${entry.id}` };
63
+ },
64
+ [QUEST_ACTIONS.toggle]: (entry) => {
65
+ const quest = this.quests.find((q) => q.id === entry.id);
66
+
67
+ if (quest) {
68
+ quest.done = !quest.done;
69
+ logger.debug("quests:state", "revert-toggle", { id: entry.id, done: quest.done });
70
+ return { success: true, message: `Reverted toggle for quest #${entry.id}` };
71
+ }
72
+
73
+ logger.debug("quests:state", "revert-toggle-not-found", { id: entry.id });
74
+ return { success: false, message: `Quest #${entry.id} not found` };
75
+ },
76
+ [QUEST_ACTIONS.update]: (entry) => {
77
+ const quest = this.quests.find((q) => q.id === entry.id);
78
+ if (quest) {
79
+ quest.description = entry.previousDescription;
80
+ logger.debug("quests:state", "revert-update", { id: entry.id });
81
+ return { success: true, message: `Reverted update for quest #${entry.id}` };
82
+ }
83
+
84
+ logger.debug("quests:state", "revert-update-not-found", { id: entry.id });
85
+ return { success: false, message: `Quest #${entry.id} not found` };
86
+ },
87
+ [QUEST_ACTIONS.delete]: (entry) => {
88
+ this.quests.splice(entry.index, 0, entry.quest);
89
+
90
+ logger.debug("quests:state", "revert-delete", {
91
+ id: entry.quest.id,
92
+ index: entry.index,
93
+ total: this.quests.length,
94
+ });
95
+ return { success: true, message: `Reverted delete for quest #${entry.quest.id}` };
96
+ },
97
+ [QUEST_ACTIONS.clear]: (entry) => {
98
+ if ("previousQuests" in entry) {
99
+ const restoredCount = entry.previousQuests.length - this.quests.length;
100
+ this.quests = [...entry.previousQuests];
101
+ this.nextId = entry.previousNextId;
102
+
103
+ return {
104
+ success: true,
105
+ message: `Reverted clear (${restoredCount} quests restored)`,
106
+ };
107
+ }
108
+
109
+ this.quests = [...entry.quests];
110
+ this.nextId = entry.nextId;
111
+
112
+ return { success: true, message: `Reverted clear (${entry.quests.length} quests restored)` };
113
+ },
114
+ [QUEST_ACTIONS.reorder]: (entry) => {
115
+ const currentIndex = this.quests.indexOf(entry.quest);
116
+ if (currentIndex === -1) return { success: false, message: "Reordered quest not found" };
117
+
118
+ this.quests.splice(currentIndex, 1);
119
+ this.quests.splice(entry.oldIndex, 0, entry.quest);
120
+ for (let i = 0; i < this.quests.length; i++) {
121
+ this.quests[i].id = entry.previousIds[i];
122
+ }
123
+
124
+ this.nextId = Math.max(...entry.previousIds, 0) + 1;
125
+ return { success: true, message: `Reverted reorder for quest #${entry.quest.id}` };
126
+ },
127
+ };
128
+
129
+ getAll(): Quest[] {
130
+ return [...this.quests];
131
+ }
132
+
133
+ getNextId(): number {
134
+ return this.nextId;
135
+ }
136
+
137
+ add(description: string, additionalContext?: string): Quest {
138
+ const quest: Quest = {
139
+ id: this.nextId++,
140
+ description,
141
+ additionalContext,
142
+ done: false,
143
+ createdAt: Date.now(),
144
+ };
145
+
146
+ this.quests.push(quest);
147
+ this.history.push({ type: QUEST_ACTIONS.add, id: quest.id });
148
+
149
+ logger.debug("quests:state", QUEST_ACTIONS.add, {
150
+ id: quest.id,
151
+ description,
152
+ total: this.quests.length,
153
+ });
154
+ return quest;
155
+ }
156
+
157
+ toggle(id: number): Quest | undefined {
158
+ const quest = this.quests.find((q) => q.id === id);
159
+ if (!quest) {
160
+ logger.debug("quests:state", "toggle-not-found", { id });
161
+ return undefined;
162
+ }
163
+
164
+ quest.done = !quest.done;
165
+ this.history.push({ type: QUEST_ACTIONS.toggle, id });
166
+
167
+ logger.debug("quests:state", QUEST_ACTIONS.toggle, {
168
+ id,
169
+ done: quest.done,
170
+ total: this.quests.length,
171
+ });
172
+ return quest;
173
+ }
174
+
175
+ update(id: number, description: string): Quest | undefined {
176
+ const quest = this.quests.find((q) => q.id === id);
177
+ if (!quest) {
178
+ logger.debug("quests:state", "update-not-found", { id });
179
+ return undefined;
180
+ }
181
+
182
+ this.history.push({ type: QUEST_ACTIONS.update, id, previousDescription: quest.description });
183
+ quest.description = description;
184
+
185
+ logger.debug("quests:state", QUEST_ACTIONS.update, {
186
+ id,
187
+ description,
188
+ total: this.quests.length,
189
+ });
190
+ return quest;
191
+ }
192
+
193
+ delete(id: number): Quest | undefined {
194
+ const index = this.quests.findIndex((q) => q.id === id);
195
+ if (index === -1) {
196
+ logger.debug("quests:state", "delete-not-found", { id });
197
+ return undefined;
198
+ }
199
+
200
+ const [quest] = this.quests.splice(index, 1);
201
+ this.history.push({ type: QUEST_ACTIONS.delete, quest, index });
202
+
203
+ logger.debug("quests:state", QUEST_ACTIONS.delete, { id, index, total: this.quests.length });
204
+ return quest;
205
+ }
206
+
207
+ clear(all = false): number {
208
+ if (!all) {
209
+ const done = this.quests.filter((q) => q.done);
210
+ const previousQuests = this.quests.map((q) => ({ ...q }));
211
+ const previousNextId = this.nextId;
212
+ this.quests = this.quests.filter((q) => !q.done);
213
+ for (let i = 0; i < this.quests.length; i++) {
214
+ this.quests[i].id = i + 1;
215
+ }
216
+ this.nextId = this.quests.length + 1;
217
+ this.history.push({
218
+ type: QUEST_ACTIONS.clear,
219
+ previousQuests,
220
+ previousNextId,
221
+ all: false,
222
+ });
223
+ logger.debug("quests:state", QUEST_ACTIONS.clear, { count: done.length, all });
224
+ return done.length;
225
+ }
226
+ const count = this.quests.length;
227
+ this.history.push({
228
+ type: QUEST_ACTIONS.clear,
229
+ quests: [...this.quests],
230
+ nextId: this.nextId,
231
+ all: true,
232
+ });
233
+ this.quests = [];
234
+ this.nextId = 1;
235
+ logger.debug("quests:state", QUEST_ACTIONS.clear, { count, all });
236
+ return count;
237
+ }
238
+
239
+ reorder(id: number, targetIndex: number): Quest | undefined {
240
+ const idx = this.quests.findIndex((q) => q.id === id);
241
+ if (idx === -1) {
242
+ logger.debug("quests:state", "reorder-not-found", { id });
243
+ return undefined;
244
+ }
245
+
246
+ const previousIds = this.quests.map((q) => q.id);
247
+ const [quest] = this.quests.splice(idx, 1);
248
+ this.quests.splice(targetIndex, 0, quest);
249
+ for (let i = 0; i < this.quests.length; i++) {
250
+ this.quests[i].id = i + 1;
251
+ }
252
+
253
+ this.nextId = this.quests.length + 1;
254
+ this.history.push({ type: QUEST_ACTIONS.reorder, quest, oldIndex: idx, previousIds });
255
+ logger.debug("quests:state", QUEST_ACTIONS.reorder, {
256
+ id: quest.id,
257
+ targetIndex,
258
+ total: this.quests.length,
259
+ });
260
+
261
+ return quest;
262
+ }
263
+
264
+ revert(): QuestOperationResult {
265
+ const entry = this.history.pop();
266
+ if (!entry) {
267
+ logger.debug("quests:state", "revert-empty");
268
+ return { success: false, message: "Nothing to revert" };
269
+ }
270
+
271
+ logger.debug("quests:state", "revert", { type: entry.type });
272
+
273
+ const handler = this.undoHandlers[entry.type];
274
+ if (handler) {
275
+ return handler(entry as never);
276
+ }
277
+
278
+ logger.debug("quests:state", "revert-unknown", { type: (entry as { type: string }).type });
279
+ return { success: false, message: "Unknown history entry" };
280
+ }
281
+
282
+ /**
283
+ * Execute a quest action and return a presentation-ready result.
284
+ * This is the primary API for all adapter layers (commands, tools).
285
+ */
286
+ execute(action: QuestAction): QuestOperationResult {
287
+ switch (action.type) {
288
+ case QUEST_ACTIONS.add: {
289
+ if (action.descriptions && action.descriptions.length > 0) {
290
+ if (action.descriptions.some((d) => !d || d.trim().length === 0)) {
291
+ return {
292
+ success: false,
293
+ message: "Error: all descriptions in a batch must be non-empty",
294
+ };
295
+ }
296
+ const added: { id: number; description: string }[] = [];
297
+ for (const desc of action.descriptions) {
298
+ const q = this.add(desc);
299
+ added.push({ id: q.id, description: q.description });
300
+ }
301
+
302
+ return { success: true, message: formatBatchAddResult(added) };
303
+ }
304
+ return {
305
+ success: false,
306
+ message: "Error: at least one description is required for add action",
307
+ };
308
+ }
309
+ case QUEST_ACTIONS.list: {
310
+ const quests = this.getAll();
311
+ return { success: true, message: formatQuestList(quests) };
312
+ }
313
+ case QUEST_ACTIONS.toggle: {
314
+ if (action.id === undefined) {
315
+ return { success: false, message: "Error: id is required for toggle action" };
316
+ }
317
+
318
+ const q = this.toggle(action.id);
319
+ if (!q) {
320
+ return { success: false, message: formatNotFound(action.id) };
321
+ }
322
+
323
+ return { success: true, message: formatToggleResult(action.id, q.done), quest: q };
324
+ }
325
+ case QUEST_ACTIONS.update: {
326
+ if (action.id === undefined) {
327
+ return { success: false, message: "Error: id is required for update action" };
328
+ }
329
+
330
+ if (!action.description) {
331
+ return { success: false, message: "Error: description is required for update action" };
332
+ }
333
+
334
+ const q = this.update(action.id, action.description);
335
+ if (!q) {
336
+ return { success: false, message: formatNotFound(action.id) };
337
+ }
338
+
339
+ return { success: true, message: formatUpdateResult(q), quest: q };
340
+ }
341
+ case QUEST_ACTIONS.delete: {
342
+ if (action.id === undefined) {
343
+ return { success: false, message: "Error: id is required for delete action" };
344
+ }
345
+
346
+ const q = this.delete(action.id);
347
+ if (!q) {
348
+ return { success: false, message: formatNotFound(action.id) };
349
+ }
350
+
351
+ return { success: true, message: formatDeleteResult(q), quest: q };
352
+ }
353
+ case QUEST_ACTIONS.clear: {
354
+ const count = this.clear(action.all);
355
+ const message = action.all
356
+ ? `Cleared ${count} quests`
357
+ : `Cleared ${count} completed quests`;
358
+
359
+ return { success: true, message };
360
+ }
361
+ case QUEST_ACTIONS.reorder: {
362
+ if (action.id === undefined)
363
+ return { success: false, message: "Error: id is required for reorder action" };
364
+
365
+ if (action.targetIndex === undefined)
366
+ return { success: false, message: "Error: targetIndex is required for reorder action" };
367
+
368
+ const q = this.reorder(action.id, action.targetIndex);
369
+ if (!q) return { success: false, message: formatNotFound(action.id) };
370
+
371
+ return { success: true, message: `Reordered quest #${q.id}: ${q.description}`, quest: q };
372
+ }
373
+ case QUEST_ACTIONS.revert: {
374
+ return this.revert();
375
+ }
376
+ default: {
377
+ return { success: false, message: `Unknown action: ${(action as { type: string }).type}` };
378
+ }
379
+ }
380
+ }
381
+
382
+ reconstructFromSession(ctx: ExtensionContext): void {
383
+ const branch = ctx.sessionManager.getBranch();
384
+ let lastState: Record<string, unknown> | undefined;
385
+ let toolResults = 0;
386
+
387
+ for (const entry of branch) {
388
+ if (
389
+ entry.type === "message" &&
390
+ "message" in entry &&
391
+ entry.message.role === "toolResult" &&
392
+ entry.message.toolName === "quest"
393
+ ) {
394
+ toolResults++;
395
+ lastState = entry.message.details;
396
+ }
397
+ }
398
+
399
+ if (lastState) {
400
+ const quests = lastState.quests;
401
+ const nextId = lastState.nextId;
402
+ const questCount = Array.isArray(quests) ? quests.length : 0;
403
+
404
+ this.quests = Array.isArray(quests) ? [...(quests as Quest[])] : [];
405
+ this.nextId = typeof nextId === "number" ? nextId : 1;
406
+ logger.debug("quests:state", "reconstruct", { toolResults, questCount, nextId: this.nextId });
407
+ } else {
408
+ this.quests = [];
409
+ this.nextId = 1;
410
+ logger.debug("quests:state", "reconstruct-empty", { toolResults });
411
+ }
412
+
413
+ this.history = [];
414
+ }
415
+ }
416
+
417
+ export function makeToolResult(
418
+ text: string,
419
+ questLog: QuestLog,
420
+ displayQuests?: Quest[],
421
+ ): AgentToolResult<unknown> {
422
+ return {
423
+ content: [{ type: "text", text }],
424
+ details: {
425
+ quests: questLog.getAll(),
426
+ nextId: questLog.getNextId(),
427
+ displayQuests,
428
+ },
429
+ };
430
+ }
@@ -0,0 +1,35 @@
1
+ export function formatQuestList(
2
+ quests: { id: number; description: string; done: boolean }[],
3
+ ): string {
4
+ if (quests.length === 0) return "No quests.";
5
+
6
+ return quests.map((q, i) => `#${i + 1} [${q.done ? "x" : " "}] ${q.description}`).join("\n");
7
+ }
8
+
9
+ export function formatAddResult(q: { id: number; description: string }): string {
10
+ return `Added quest #${q.id}: ${q.description}`;
11
+ }
12
+
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")}`;
15
+ }
16
+
17
+ export function formatToggleResult(id: number, done: boolean): string {
18
+ return `Quest #${id} ${done ? "done" : "undone"}`;
19
+ }
20
+
21
+ export function formatUpdateResult(q: { id: number; description: string }): string {
22
+ return `Updated quest #${q.id}: ${q.description}`;
23
+ }
24
+
25
+ export function formatDeleteResult(q: { id: number; description: string }): string {
26
+ return `Deleted quest #${q.id}: ${q.description}`;
27
+ }
28
+
29
+ export function formatClearResult(count: number): string {
30
+ return `Cleared ${count} quests`;
31
+ }
32
+
33
+ export function formatNotFound(id: number): string {
34
+ return `Quest #${id} not found`;
35
+ }
@@ -0,0 +1,87 @@
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.";
18
+
19
+ export class QuestUsageTracker {
20
+ private totalToolCalls = 0;
21
+ private consecutiveNonQuestToolCalls = 0;
22
+ private hasEverUsedQuestTool = false;
23
+ private lastQuestToolTime = 0;
24
+ private nudgedThisTurn = false;
25
+
26
+ onToolExecution(toolName: string): void {
27
+ this.totalToolCalls++;
28
+ if (toolName === "quest") {
29
+ this.hasEverUsedQuestTool = true;
30
+ this.lastQuestToolTime = Date.now();
31
+ this.consecutiveNonQuestToolCalls = 0;
32
+ } else {
33
+ this.consecutiveNonQuestToolCalls++;
34
+ }
35
+ }
36
+
37
+ clearTurnNudge(): void {
38
+ this.nudgedThisTurn = false;
39
+ }
40
+
41
+ getNudge(activeQuestCount: number, latestPrompt?: string): string | undefined {
42
+ if (this.nudgedThisTurn) return undefined;
43
+
44
+ // 1. Initialization nudge
45
+ if (this.totalToolCalls >= 3 && !this.hasEverUsedQuestTool) {
46
+ 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}`;
48
+ }
49
+
50
+ // 2. Complex-task entrypoint nudge
51
+ 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}`;
54
+ }
55
+
56
+ // 3. Time-based alignment nudge
57
+ if (
58
+ this.hasEverUsedQuestTool &&
59
+ this.lastQuestToolTime > 0 &&
60
+ this.consecutiveNonQuestToolCalls >= 3 &&
61
+ // 8 minutes without quest tool use
62
+ Date.now() - this.lastQuestToolTime >= 8 * 60 * 1000
63
+ ) {
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}`;
66
+ }
67
+
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}`;
72
+ }
73
+
74
+ // 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}`;
78
+ }
79
+
80
+ return undefined;
81
+ }
82
+
83
+ private isComplexPrompt(prompt: string): boolean {
84
+ const lower = prompt.toLowerCase();
85
+ return COMPLEX_TASK_KEYWORDS.some((kw) => lower.includes(kw));
86
+ }
87
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Core domain types and action constants for the quest system.
3
+ */
4
+
5
+ export const QUEST_ACTIONS = {
6
+ add: "add",
7
+ list: "list",
8
+ toggle: "toggle",
9
+ update: "update",
10
+ delete: "delete",
11
+ clear: "clear",
12
+ reorder: "reorder",
13
+ revert: "revert",
14
+ } as const;
15
+
16
+ export const QUEST_ACTION_VALUES = [
17
+ QUEST_ACTIONS.add,
18
+ QUEST_ACTIONS.list,
19
+ QUEST_ACTIONS.toggle,
20
+ QUEST_ACTIONS.update,
21
+ QUEST_ACTIONS.delete,
22
+ QUEST_ACTIONS.clear,
23
+ QUEST_ACTIONS.reorder,
24
+ QUEST_ACTIONS.revert,
25
+ ] as const;
26
+
27
+ export type QuestActionType = (typeof QUEST_ACTION_VALUES)[number];
28
+
29
+ export interface Quest {
30
+ id: number;
31
+ description: string;
32
+ additionalContext?: string;
33
+ done: boolean;
34
+ createdAt: number;
35
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
3
  import { logger } from "../logger.js";
4
- import type { Quest } from "../quests.js";
4
+ import type { Quest } from "../quest/types.js";
5
5
 
6
6
  export class QuestListWidget {
7
7
  private cachedWidth?: number;
@@ -24,10 +24,12 @@ export class QuestListWidget {
24
24
  this.onClose();
25
25
  return;
26
26
  }
27
+
27
28
  if (matchesKey(data, Key.tab)) {
28
29
  this.nextPage();
29
30
  return;
30
31
  }
32
+
31
33
  if (matchesKey(data, Key.shift(Key.tab))) {
32
34
  this.prevPage();
33
35
  return;
@@ -45,6 +47,7 @@ export class QuestListWidget {
45
47
  to: next,
46
48
  totalPages: this.totalPages,
47
49
  });
50
+
48
51
  this.page = next;
49
52
  this.invalidate();
50
53
  }
@@ -56,6 +59,7 @@ export class QuestListWidget {
56
59
  to: prev,
57
60
  totalPages: this.totalPages,
58
61
  });
62
+
59
63
  this.page = prev;
60
64
  this.invalidate();
61
65
  }
@@ -100,24 +104,29 @@ export class QuestListWidget {
100
104
  const filled = Math.round((doneCount / total) * barWidth);
101
105
  const empty = barWidth - filled;
102
106
  const bar = th.fg("success", "█".repeat(filled)) + th.fg("dim", "░".repeat(empty));
107
+
103
108
  lines.push(truncateToWidth(` ${bar} ${th.fg("muted", `${doneCount}/${total}`)}`, width));
104
109
  lines.push("");
105
110
 
106
111
  const start = this.page * this.pageSize;
107
112
  const pageQuests = this.quests.slice(start, start + this.pageSize);
108
- for (const q of pageQuests) {
113
+ for (let i = 0; i < pageQuests.length; i++) {
114
+ const q = pageQuests[i];
115
+ const pos = start + i + 1;
109
116
  const marker = q.done ? th.fg("success", " ✓ ") : th.fg("muted", " ○ ");
110
- const idStr = th.fg(q.done ? "dim" : "accent", `#${q.id}`);
117
+ const idStr = th.fg(q.done ? "dim" : "accent", `#${pos}`);
111
118
  const desc = q.done
112
119
  ? th.fg("dim", th.strikethrough(q.description))
113
120
  : th.fg("text", q.description);
114
121
  const row = `${marker}${idStr} ${desc}`;
122
+
115
123
  lines.push(truncateToWidth(row, width));
116
124
  }
117
125
 
118
126
  if (this.totalPages > 1) {
119
- lines.push("");
120
127
  const pageInfo = ` Page ${this.page + 1}/${this.totalPages} ${th.fg("dim", "· Tab/Shift+Tab to navigate")}`;
128
+
129
+ lines.push("");
121
130
  lines.push(truncateToWidth(pageInfo, width));
122
131
  }
123
132
  }