pi-note 0.13.2 → 0.14.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.
- package/README.md +19 -27
- package/icons/add.txt +1 -0
- package/index.ts +53 -11
- package/package.json +1 -1
- package/preview-bridge.ts +23 -2
- package/preview-click.cjs +2 -2
- package/task-suggestion.ts +22 -13
package/README.md
CHANGED
|
@@ -68,9 +68,7 @@ task, so existing values can be configured without creating another task.
|
|
|
68
68
|
Type in the task picker to filter by text, ID, tag, or state (all words must match).
|
|
69
69
|
Use ↑/↓ and Enter to select, or Esc to cancel. Rows show **●** for in progress or
|
|
70
70
|
**○** for active, followed by the tag (`[Untagged]` when absent).
|
|
71
|
-
The normal Pi status area shows
|
|
72
|
-
The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the pin and tag). Overflow ends with `...`.
|
|
73
|
-
The pin glyph is bundled in `icons/note.txt`; it uses terminal fonts, not a Nerd Font or icon theme.
|
|
71
|
+
The normal Pi status area shows selected-task controls and title without replacing other footers. The footer omits the task ID and state and shows only the first line, bounded by `footer.titleWidth` (default 60 terminal columns including the pin, controls, tag and title). Overflow ends with `...`. The title remains clickable for preview; task controls are separate. At very small widths controls are hidden (pin only). The pin and controls are bundled in `icons/note.txt`, `icons/check.txt`, `icons/cross.txt`, and `icons/add.txt`; they use terminal fonts, not a Nerd Font or icon theme.
|
|
74
72
|
|
|
75
73
|
### Display-only preview
|
|
76
74
|
|
|
@@ -186,32 +184,26 @@ When allowed by `taskOfferPolicy`, the agent uses `pinote_propose` instead of
|
|
|
186
184
|
asking in chat. The pin footer shows a pending suggestion, distinct from a selected task:
|
|
187
185
|
|
|
188
186
|
```text
|
|
189
|
-
📌
|
|
187
|
+
📌 ✕ + · [tag] Task title...
|
|
188
|
+
📌 ✓ · [tag] Task title...
|
|
190
189
|
```
|
|
191
190
|
|
|
192
|
-

|
|
192
|
+

|
|
193
|
+
|
|
194
|
+
- Muted **✕** dismisses without creating a note; further offers are suppressed until a new session or reload.
|
|
195
|
+
- Accent **+** creates, starts, and selects the suggested task.
|
|
196
|
+
- Green **✓** completes the selected task and clears the selection on success.
|
|
197
|
+
|
|
198
|
+
Done occupies the former ✕ position, not the + position, so clicking Add twice cannot accidentally complete the new task. The former + cell stays blank. The selected task's title is a separate preview link.
|
|
199
|
+
|
|
200
|
+
The configured `footer.titleWidth` bounds the entire row (default 60 columns). Controls stay first; the tag is dropped before truncating the first-line title with `...`. Tiny widths hide controls (pin only). Pi can still clip the combined status row on narrow terminals.
|
|
201
|
+
|
|
202
|
+
Controls use the [Kitty preview handler](#display-only-preview), with no additional configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are `/pi-note-no`, `/pi-note-yes`, and `/pi-note-done`. No action submits input or starts an agent turn; editor drafts stay unchanged.
|
|
203
|
+
|
|
204
|
+
Completion requires an idle Pi session and no pending Pinote operation. It rejects stale selected-task links and externally changed revisions. Accepted mutations drain even if the helper disconnects. On an error, check `/pi-note` before retrying, because a write may already have committed.
|
|
205
|
+
|
|
206
|
+
An existing suggestion is retained rather than replaced. Selection, tree navigation, reload and shutdown invalidate its links; repeated acceptance cannot duplicate a note. A busy Pinote operation blocks acceptance. Noninteractive agents still ask in chat before using `pinote_add` with `select: true`. An existing selection is not replaced unless the user asks to switch. Reuse a saved
|
|
215
207
|
tag name when it fits.
|
|
216
208
|
Task text starts with a short, action-oriented title (aim for at most 60 characters).
|
|
217
209
|
Put context, URLs, commands, and acceptance criteria after a blank line; the title
|
package/icons/add.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
+
|
package/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts"
|
|
|
10
10
|
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
11
11
|
import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
|
|
12
12
|
import { createPreviewBridge } from "./preview-bridge.ts";
|
|
13
|
-
import { renderSuggestion } from "./task-suggestion.ts";
|
|
13
|
+
import { renderSuggestion, renderSelectedTask } from "./task-suggestion.ts";
|
|
14
14
|
|
|
15
15
|
const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep a GitHub pull request in the PR field. Read pinote_fields to learn the user's globally configured footer field names and presentation. Populate those fields with meaningful task values when relevant using pinote_update_current; fields without values stay hidden. Footer fields and widths are chosen by the user's pi-note.json config. Set configured fields with Markdown values; links are clickable. When footer.fields is unset, Bar selects labels, one per line. Do not list PR in Bar. Remove a field to hide it. Do not modify config without user approval.";
|
|
16
16
|
const offerGuidance = {
|
|
@@ -18,7 +18,7 @@ const offerGuidance = {
|
|
|
18
18
|
"github-remote": "When the user gives work and no pinote task is selected, first verify with Git that the current repository has a remote whose URL host is github.com (HTTPS or SSH). Only then use pinote_propose to show one suggested `[tag] task` in the bottom bar instead of asking in chat. In noninteractive modes, ask before creating it. Local paths, other hosts, and GitHub-looking URL paths do not qualify. If no GitHub remote is verified, do not offer a task; explicit user requests to create one remain allowed.",
|
|
19
19
|
never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
|
|
20
20
|
};
|
|
21
|
-
const consentGuidance = "The suggestion bar's
|
|
21
|
+
const consentGuidance = "The suggestion bar's plus creates, starts and selects the note; its cross dismisses it. Continue the requested work while the suggestion is pending; do not ask again in chat or create a note yourself. On a chat no, continue without a note; on a chat yes, call pinote_add with select true. Never add or select without consent. If a task is already selected, do not replace it unless the user asks to switch.";
|
|
22
22
|
const titleGuidance = "Start task text with a short, action-oriented summary (aim for at most 60 characters). Put context, URLs, commands, and acceptance criteria after a blank line. Never put implementation details in the title.";
|
|
23
23
|
const tagGuidance = "Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. Tags are set when creating tasks with pinote_add; agent tools cannot retag existing tasks. Do not use pinote_update_current for tags or task text.";
|
|
24
24
|
type Task = {
|
|
@@ -228,7 +228,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
228
228
|
const current = task(await run(["agent", "get", String(id)], signal, canUse));
|
|
229
229
|
if (!canUse() || selectedId !== id) return null;
|
|
230
230
|
if (!current || current.id !== id || !["active", "in_progress"].includes(current.state)) {
|
|
231
|
-
if (canUse() && selectedId === id) remember(null);
|
|
231
|
+
if (!pending && canUse() && selectedId === id) remember(null);
|
|
232
232
|
return null;
|
|
233
233
|
}
|
|
234
234
|
return current;
|
|
@@ -267,11 +267,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
267
267
|
const previewId = current?.id ?? null;
|
|
268
268
|
const previewBranch = branchEpoch;
|
|
269
269
|
previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
|
|
270
|
-
selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
270
|
+
selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false), current ? {
|
|
271
|
+
revision: current.updated_at,
|
|
272
|
+
invoke: () => alive && generation === epoch && previewBranch === branchEpoch && selectedId === previewId
|
|
273
|
+
? completeTask(ctx, current) : Promise.resolve(false),
|
|
274
|
+
} : undefined);
|
|
275
|
+
ctx.ui.setStatus("pinote", current ? renderSelectedTask(current, footerConfig.titleWidth, ctx.ui.theme, {
|
|
276
|
+
preview: previewBridge?.url(), done: previewBridge?.doneUrl(),
|
|
277
|
+
}) : suggestionStatus(ctx));
|
|
275
278
|
watcher.update(ctx, current);
|
|
276
279
|
}
|
|
277
280
|
} catch {
|
|
@@ -327,6 +330,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
327
330
|
if (pending === operation) pending = undefined;
|
|
328
331
|
}
|
|
329
332
|
};
|
|
333
|
+
const completeTask = async (ctx: ExtensionContext, displayed?: Task): Promise<boolean> => {
|
|
334
|
+
if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || setupAbort || !validId(selectedId)) return false;
|
|
335
|
+
const generation = epoch;
|
|
336
|
+
const branch = branchEpoch;
|
|
337
|
+
const id = selectedId;
|
|
338
|
+
const operation = Symbol();
|
|
339
|
+
pending = operation;
|
|
340
|
+
refreshSerial++;
|
|
341
|
+
const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle();
|
|
342
|
+
let wrote = false;
|
|
343
|
+
try {
|
|
344
|
+
const current = displayed ?? await currentTask(undefined, canAct);
|
|
345
|
+
if (!current || current.id !== id || !canAct()) return false;
|
|
346
|
+
const completed = requiredTask(await run([
|
|
347
|
+
"agent", "done", String(id), "--expected-updated-at", current.updated_at,
|
|
348
|
+
], undefined, canAct), id);
|
|
349
|
+
wrote = true;
|
|
350
|
+
if (completed.state !== "done") throw new Error(compatible);
|
|
351
|
+
if (!canAct()) throw new Error("The session or selection changed. Completion may have committed; read the task before retrying.");
|
|
352
|
+
remember(null, branch);
|
|
353
|
+
ctx.ui.notify(`Pinote #${id} completed.`, "info");
|
|
354
|
+
return true;
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (alive && generation === epoch) ctx.ui.notify(
|
|
357
|
+
`Pinote completion: ${clean(String(error))}${wrote ? " Check the task before retrying." : ""}`, "error");
|
|
358
|
+
return false;
|
|
359
|
+
} finally {
|
|
360
|
+
if (pending === operation) pending = undefined;
|
|
361
|
+
if (alive && generation === epoch) await refresh(ctx);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
pi.registerCommand("pi-note-done", {
|
|
365
|
+
description: "Complete the selected task (same as the footer ✓)",
|
|
366
|
+
handler: async (args, ctx) => {
|
|
367
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
368
|
+
if (args.trim()) { ctx.ui.notify("Usage: /pi-note-done", "warning"); return; }
|
|
369
|
+
if (!await completeTask(ctx)) ctx.ui.notify("Select a task and wait until Pi is idle and Pinote is available before completing it.", "warning");
|
|
370
|
+
},
|
|
371
|
+
});
|
|
330
372
|
pi.registerCommand("pi-note-preview", {
|
|
331
373
|
description: "Display the selected task locally without sending it to the model",
|
|
332
374
|
handler: async (args, ctx) => {
|
|
@@ -649,7 +691,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
649
691
|
}
|
|
650
692
|
};
|
|
651
693
|
pi.registerCommand("pi-note-yes", {
|
|
652
|
-
description: "Create, start and select the suggested task (same as
|
|
694
|
+
description: "Create, start and select the suggested task (same as +)",
|
|
653
695
|
handler: async (args, ctx) => {
|
|
654
696
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
655
697
|
if (args.trim() || !await respondToSuggestion("yes", ctx)) ctx.ui.notify("No available suggestion, or a Pinote operation is busy. Retry when it finishes.", "warning");
|
|
@@ -666,7 +708,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
666
708
|
name: "pinote_propose",
|
|
667
709
|
label: "Pinote suggest task",
|
|
668
710
|
promptGuidelines: createGuidance,
|
|
669
|
-
description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat.
|
|
711
|
+
description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat. + creates, starts and selects it; ✕ dismisses it. Continue the requested work while awaiting consent. Requires an interactive TUI with no selected task. An existing suggestion is retained; a dismissed offer is not repeated. In noninteractive modes ask in chat, then use pinote_add only after consent.",
|
|
670
712
|
parameters: Type.Object({
|
|
671
713
|
text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
|
|
672
714
|
tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
@@ -717,7 +759,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
717
759
|
pending = operation;
|
|
718
760
|
refreshSerial++;
|
|
719
761
|
const canAct = () => alive && generation === epoch && branch === branchEpoch && pending === operation && selectedId === previousSelection;
|
|
720
|
-
// Chat consent consumes the same suggestion as clicking
|
|
762
|
+
// Chat consent consumes the same suggestion as clicking +, even if start fails.
|
|
721
763
|
clearSuggestion(ctx);
|
|
722
764
|
try {
|
|
723
765
|
const created = requiredTask(await run(argv, signal, canAct, "0.4.0"));
|
package/package.json
CHANGED
package/preview-bridge.ts
CHANGED
|
@@ -16,10 +16,11 @@ export function createPreviewBridge() {
|
|
|
16
16
|
let taskId: number | null = null;
|
|
17
17
|
let callback: ((signal: AbortSignal) => Promise<boolean>) | undefined;
|
|
18
18
|
let nonce: string | undefined;
|
|
19
|
+
let completion: { revision: string; nonce: string; invoke: () => Promise<boolean> } | undefined;
|
|
19
20
|
let suggestionCallback: ((choice: "yes" | "no") => Promise<boolean>) | undefined;
|
|
20
21
|
let suggestionNonce: string | undefined;
|
|
21
22
|
const clients = new Set<Socket>();
|
|
22
|
-
const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; };
|
|
23
|
+
const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; completion = undefined; };
|
|
23
24
|
const invalidateSuggestion = () => { suggestionCallback = undefined; suggestionNonce = undefined; };
|
|
24
25
|
const cleanup = async () => {
|
|
25
26
|
ready = false;
|
|
@@ -66,6 +67,7 @@ export function createPreviewBridge() {
|
|
|
66
67
|
dispatched = true;
|
|
67
68
|
const input = data.toString("utf8");
|
|
68
69
|
const previewMatch = /^preview ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
|
|
70
|
+
const completionMatch = /^done ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
|
|
69
71
|
const suggestionMatch = /^(yes|no) ([0-9a-f]{32}) 1 ([0-9a-f]{32})\n$/u.exec(input);
|
|
70
72
|
const id = previewMatch ? Number(previewMatch[2]) : NaN;
|
|
71
73
|
if (previewMatch && previewMatch[0] === input && previewMatch[1] === capability && Number.isSafeInteger(id) &&
|
|
@@ -78,6 +80,18 @@ export function createPreviewBridge() {
|
|
|
78
80
|
() => { if (!client.destroyed) client.end("rejected\n"); });
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
83
|
+
if (completionMatch && completionMatch[0] === input && completionMatch[1] === capability &&
|
|
84
|
+
Number(completionMatch[2]) === taskId && completionMatch[3] === completion?.nonce && completion) {
|
|
85
|
+
client.setTimeout(65000);
|
|
86
|
+
const acceptedCompletion = completion;
|
|
87
|
+
const acceptedId = taskId;
|
|
88
|
+
// Accepted writes drain even if the helper disconnects.
|
|
89
|
+
Promise.resolve().then(() => !closed && taskId === acceptedId && completion === acceptedCompletion
|
|
90
|
+
? acceptedCompletion.invoke() : false)
|
|
91
|
+
.then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
|
|
92
|
+
() => { if (!client.destroyed) client.end("rejected\n"); });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
81
95
|
if (suggestionMatch && suggestionMatch[0] === input && suggestionMatch[2] === capability && suggestionMatch[3] === suggestionNonce && suggestionCallback) {
|
|
82
96
|
client.setTimeout(65000);
|
|
83
97
|
const invoke = suggestionCallback;
|
|
@@ -111,10 +125,13 @@ export function createPreviewBridge() {
|
|
|
111
125
|
})();
|
|
112
126
|
return starting;
|
|
113
127
|
},
|
|
114
|
-
setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>): void {
|
|
128
|
+
setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>, done?: { revision: string; invoke: () => Promise<boolean> }): void {
|
|
115
129
|
if (closed) return;
|
|
116
130
|
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) throw new Error("Invalid preview task");
|
|
117
131
|
if (id !== taskId) nonce = id === null ? undefined : randomBytes(16).toString("hex");
|
|
132
|
+
if (id === null || !done) completion = undefined;
|
|
133
|
+
else completion = { revision: done.revision, invoke: done.invoke,
|
|
134
|
+
nonce: id === taskId && completion?.revision === done.revision ? completion.nonce : randomBytes(16).toString("hex") };
|
|
118
135
|
taskId = id;
|
|
119
136
|
callback = id === null ? undefined : preview;
|
|
120
137
|
},
|
|
@@ -123,6 +140,10 @@ export function createPreviewBridge() {
|
|
|
123
140
|
if (!ready || closed || !socketPath || taskId === null || !nonce) return;
|
|
124
141
|
return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${nonce}`;
|
|
125
142
|
},
|
|
143
|
+
doneUrl(): string | undefined {
|
|
144
|
+
if (!ready || closed || !socketPath || taskId === null || !completion) return;
|
|
145
|
+
return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${completion.nonce}/done`;
|
|
146
|
+
},
|
|
126
147
|
setSuggestion(suggestion: (choice: "yes" | "no") => Promise<boolean>): void {
|
|
127
148
|
if (closed) return;
|
|
128
149
|
suggestionCallback = suggestion;
|
package/preview-click.cjs
CHANGED
|
@@ -4,12 +4,12 @@ const net = require('node:net');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
|
|
7
|
-
const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})(?:\/(yes|no))?$/;
|
|
7
|
+
const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})(?:\/(yes|no|done))?$/;
|
|
8
8
|
|
|
9
9
|
function parse(value) {
|
|
10
10
|
if (typeof value !== 'string' || value.length > 180) throw new Error('Preview or suggestion unavailable');
|
|
11
11
|
const m = urlPattern.exec(value);
|
|
12
|
-
if (!m || m[0] !== value || (m[6] && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
|
|
12
|
+
if (!m || m[0] !== value || (m[6] && m[6] !== 'done' && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
|
|
13
13
|
const pid = Number(m[1]), id = Number(m[4]);
|
|
14
14
|
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(id) || pid <= 0 || id <= 0) throw new Error('Preview or suggestion unavailable');
|
|
15
15
|
return { pid, tag: m[2], capability: m[3], id, nonce: m[5], choice: m[6] };
|
package/task-suggestion.ts
CHANGED
|
@@ -6,27 +6,36 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
const pin = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
|
|
7
7
|
const check = readFileSync(new URL("./icons/check.txt", import.meta.url), "utf8").trim();
|
|
8
8
|
const cross = readFileSync(new URL("./icons/cross.txt", import.meta.url), "utf8").trim();
|
|
9
|
+
const add = readFileSync(new URL("./icons/add.txt", import.meta.url), "utf8").trim();
|
|
10
|
+
// Pi's default footer collapses ASCII spaces; NBSP preserves the control cells.
|
|
11
|
+
const gap = "\u00a0";
|
|
9
12
|
const link = (label: string, url?: string) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
|
|
13
|
+
type Task = { text: string; tag?: string | null };
|
|
14
|
+
type Colors = Pick<Theme, "fg">;
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
): string {
|
|
15
|
-
const cue = `${pin} ${theme.fg("accent", "Suggested:")}`;
|
|
16
|
-
const actions = (compact: boolean) => `${theme.fg("success", link(compact ? check : `${check} Add`, urls.yes))} ${theme.fg("muted", link(compact ? cross : `${cross} Dismiss`, urls.no))}`;
|
|
17
|
-
let prefix = `${cue} ${actions(false)}`;
|
|
18
|
-
if (visibleWidth(prefix) > width) prefix = `${cue} ${actions(true)}`;
|
|
19
|
-
// Never show unexplained action icons when even the suggestion cue cannot fit.
|
|
20
|
-
if (visibleWidth(prefix) > width) return truncateToWidth(cue, width, "");
|
|
16
|
+
function renderTask(task: Task, width: number, theme: Colors, prefix: string, previewUrl?: string): string {
|
|
17
|
+
// All controls or none: never let a narrow budget reveal only one pending choice.
|
|
18
|
+
if (visibleWidth(prefix) > width) return truncateToWidth(pin, width, "");
|
|
21
19
|
const remaining = width - visibleWidth(prefix) - 3; // separator: " · "
|
|
22
|
-
if (remaining < 4) return prefix;
|
|
20
|
+
if (remaining < 4) return prefix.trimEnd();
|
|
23
21
|
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").replace(/\s+/gu, " ").trim();
|
|
24
22
|
const tag = `[${clean(task.tag ?? "Untagged")}]`;
|
|
25
23
|
const title = clean(task.text.split("\n", 1)[0]);
|
|
26
|
-
// Drop the tag before sacrificing the title to a long tag or a small budget.
|
|
27
24
|
const tagWidth = visibleWidth(tag) + 1;
|
|
28
25
|
const label = remaining >= tagWidth + Math.min(12, visibleWidth(title))
|
|
29
26
|
? `${theme.fg("muted", tag)} ${theme.fg("text", truncateToWidth(title, remaining - tagWidth, "..."))}`
|
|
30
27
|
: theme.fg("text", truncateToWidth(title, remaining, "..."));
|
|
31
|
-
return `${prefix}${theme.fg("muted", " · ")}${label}`;
|
|
28
|
+
return `${prefix}${theme.fg("muted", " · ")}${link(label, previewUrl)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderSuggestion(task: Task, width: number, theme: Colors, urls: { yes?: string; no?: string }): string {
|
|
32
|
+
return renderTask(task, width, theme,
|
|
33
|
+
`${pin} ${theme.fg("muted", link(cross, urls.no))}${gap.repeat(2)}${theme.fg("accent", link(add, urls.yes))}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderSelectedTask(task: Task, width: number, theme: Colors, urls: { preview?: string; done?: string }): string {
|
|
37
|
+
// Reserve the former '+' cell: accepting twice at the same position cannot click Done.
|
|
38
|
+
return renderTask(task, width, theme,
|
|
39
|
+
`${pin} ${theme.fg("success", link(check, urls.done))}${gap.repeat(visibleWidth(cross) + 2 + visibleWidth(add) - visibleWidth(check))}`,
|
|
40
|
+
urls.preview);
|
|
32
41
|
}
|