@bumpyclock/pi-tasque 0.1.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,264 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { selectTaskSubjectById } from "../state/selectors.js";
4
+ import type { TaskState } from "../state/state.js";
5
+ import type {
6
+ Task,
7
+ TaskAction,
8
+ TaskMutationParams,
9
+ TaskStatus,
10
+ } from "../tool/types.js";
11
+
12
+ export const STATUS_GLYPH: Record<TaskStatus, string> = {
13
+ pending: "○",
14
+ in_progress: "◐",
15
+ completed: "●",
16
+ deleted: "⊘",
17
+ };
18
+
19
+ export const STATUS_COLOR: Record<
20
+ TaskStatus,
21
+ "dim" | "warning" | "success" | "muted"
22
+ > = {
23
+ pending: "dim",
24
+ in_progress: "warning",
25
+ completed: "success",
26
+ deleted: "muted",
27
+ };
28
+
29
+ export const ACTION_GLYPH: Record<TaskAction, string> = {
30
+ create: "+",
31
+ update: "→",
32
+ delete: "×",
33
+ get: "›",
34
+ list: "☰",
35
+ clear: "∅",
36
+ };
37
+
38
+ type RenderableTaskDetails = {
39
+ action: TaskAction;
40
+ params: Record<string, unknown>;
41
+ tasks: unknown[];
42
+ error?: string;
43
+ };
44
+
45
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
46
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
47
+ const prototype = Object.getPrototypeOf(value);
48
+ return prototype === Object.prototype || prototype === null;
49
+ }
50
+
51
+ function isTaskAction(value: unknown): value is TaskAction {
52
+ return typeof value === "string" && Object.hasOwn(ACTION_GLYPH, value);
53
+ }
54
+
55
+ function isTaskStatus(value: unknown): value is TaskStatus {
56
+ return typeof value === "string" && Object.hasOwn(STATUS_GLYPH, value);
57
+ }
58
+
59
+ function statusFromTask(value: unknown): TaskStatus | undefined {
60
+ if (!isPlainObject(value)) return undefined;
61
+ return isTaskStatus(value.status) ? value.status : undefined;
62
+ }
63
+
64
+ function isRenderableTaskDetails(
65
+ value: unknown,
66
+ ): value is RenderableTaskDetails {
67
+ return (
68
+ isPlainObject(value) &&
69
+ isTaskAction(value.action) &&
70
+ Array.isArray(value.tasks) &&
71
+ isPlainObject(value.params) &&
72
+ (value.error === undefined || typeof value.error === "string")
73
+ );
74
+ }
75
+
76
+ function firstContentText(value: unknown): string | undefined {
77
+ if (!Array.isArray(value)) return undefined;
78
+ const textContent = value.find(
79
+ (candidate) =>
80
+ isPlainObject(candidate) &&
81
+ candidate.type === "text" &&
82
+ typeof candidate.text === "string",
83
+ );
84
+ return isPlainObject(textContent) && typeof textContent.text === "string"
85
+ ? textContent.text
86
+ : undefined;
87
+ }
88
+
89
+ function errorMessageFromResult(result: {
90
+ details?: unknown;
91
+ content?: unknown;
92
+ }): string | undefined {
93
+ if (
94
+ !isPlainObject(result.details) ||
95
+ !Object.hasOwn(result.details, "error")
96
+ ) {
97
+ return undefined;
98
+ }
99
+
100
+ if (
101
+ typeof result.details.error === "string" &&
102
+ result.details.error.length > 0
103
+ ) {
104
+ return result.details.error;
105
+ }
106
+
107
+ return firstContentText(result.content) ?? "Error";
108
+ }
109
+
110
+ export function formatStatusLabel(status: TaskStatus): string {
111
+ switch (status) {
112
+ case "pending":
113
+ return "pending";
114
+ case "in_progress":
115
+ return "in progress";
116
+ case "completed":
117
+ return "completed";
118
+ case "deleted":
119
+ return "deleted";
120
+ }
121
+ }
122
+
123
+ export function overlayStatusGlyph(status: TaskStatus, theme: Theme): string {
124
+ switch (status) {
125
+ case "pending":
126
+ return theme.fg("dim", "○");
127
+ case "in_progress":
128
+ return theme.fg("warning", "◐");
129
+ case "completed":
130
+ return theme.fg("success", "✓");
131
+ case "deleted":
132
+ return theme.fg("error", "✗");
133
+ }
134
+ }
135
+
136
+ /** Format a single task row for the above-editor todo overlay. */
137
+ export function formatOverlayTaskLine(
138
+ task: Task,
139
+ theme: Theme,
140
+ showId: boolean,
141
+ ): string {
142
+ const glyph = overlayStatusGlyph(task.status, theme);
143
+ const subjectColor =
144
+ task.status === "completed" || task.status === "deleted" ? "dim" : "text";
145
+ let subject = theme.fg(subjectColor, task.subject);
146
+ if (task.status === "completed" || task.status === "deleted") {
147
+ subject = theme.strikethrough(subject);
148
+ }
149
+
150
+ let line = glyph;
151
+ if (showId) line += ` ${theme.fg("accent", `#${task.id}`)}`;
152
+ line += ` ${subject}`;
153
+ if (task.status === "in_progress" && task.activeForm) {
154
+ line += ` ${theme.fg("dim", `(${task.activeForm})`)}`;
155
+ }
156
+ if (task.blockedBy?.length) {
157
+ line += ` ${theme.fg(
158
+ "dim",
159
+ `⛓ ${task.blockedBy.map((id) => `#${id}`).join(",")}`,
160
+ )}`;
161
+ }
162
+ return line;
163
+ }
164
+
165
+ /** Format a single task row for the `/todos` command body. */
166
+ export function formatCommandTaskLine(task: Task, glyph: string): string {
167
+ const activeForm =
168
+ task.status === "in_progress" && task.activeForm
169
+ ? ` (${task.activeForm})`
170
+ : "";
171
+ const blockedBy = task.blockedBy?.length
172
+ ? ` ⛓ ${task.blockedBy.map((id) => `#${id}`).join(",")}`
173
+ : "";
174
+ return ` ${glyph} #${task.id} ${task.subject}${activeForm}${blockedBy}`;
175
+ }
176
+
177
+ /** Render the compact todo tool-call label shown in chat. */
178
+ export function renderTodoCall(
179
+ args: TaskMutationParams & { action: TaskAction },
180
+ theme: Theme,
181
+ state: TaskState,
182
+ ): Text {
183
+ const glyph = ACTION_GLYPH[args.action];
184
+ let text = `${theme.fg("toolTitle", theme.bold("todo "))} ${theme.fg(
185
+ "muted",
186
+ glyph,
187
+ )}`;
188
+
189
+ if (args.action === "create" && args.subject) {
190
+ text += ` ${theme.fg("dim", args.subject)}`;
191
+ } else if (
192
+ (args.action === "update" ||
193
+ args.action === "get" ||
194
+ args.action === "delete") &&
195
+ args.id !== undefined
196
+ ) {
197
+ const subject = selectTaskSubjectById(state, args.id);
198
+ text += ` ${theme.fg("accent", subject ?? `#${args.id}`)}`;
199
+ } else if (args.action === "list" && args.status) {
200
+ text += ` ${theme.fg("muted", formatStatusLabel(args.status))}`;
201
+ }
202
+
203
+ return new Text(text, 0, 0);
204
+ }
205
+
206
+ /** Render the compact todo result status shown in chat. */
207
+ export function renderTodoResult(
208
+ result: { details?: unknown; content?: unknown },
209
+ theme: Theme,
210
+ ): Text {
211
+ const errorMessage = errorMessageFromResult(result);
212
+ if (errorMessage) {
213
+ return new Text(theme.fg("error", `✗ ${errorMessage}`), 0, 0);
214
+ }
215
+
216
+ const details = isRenderableTaskDetails(result.details)
217
+ ? result.details
218
+ : undefined;
219
+ let status: TaskStatus | undefined;
220
+
221
+ if (details) {
222
+ switch (details.action) {
223
+ case "create":
224
+ status = statusFromTask(details.tasks.at(-1));
225
+ break;
226
+ case "update":
227
+ if (details.params.status !== undefined) {
228
+ status = isTaskStatus(details.params.status)
229
+ ? details.params.status
230
+ : undefined;
231
+ } else {
232
+ status = statusFromTask(
233
+ details.tasks.find(
234
+ (candidate) =>
235
+ isPlainObject(candidate) && candidate.id === details.params.id,
236
+ ),
237
+ );
238
+ }
239
+ break;
240
+ case "delete":
241
+ status = statusFromTask(
242
+ details.tasks.find(
243
+ (candidate) =>
244
+ isPlainObject(candidate) && candidate.id === details.params.id,
245
+ ),
246
+ );
247
+ break;
248
+ case "list":
249
+ case "get":
250
+ case "clear":
251
+ break;
252
+ }
253
+ }
254
+
255
+ if (!status) return new Text(theme.fg("success", "✓"), 0, 0);
256
+ return new Text(
257
+ theme.fg(
258
+ STATUS_COLOR[status],
259
+ `${STATUS_GLYPH[status]} ${formatStatusLabel(status)}`,
260
+ ),
261
+ 0,
262
+ 0,
263
+ );
264
+ }
@@ -0,0 +1,81 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ truncateText,
4
+ type TruncatedText,
5
+ type TruncationOptions,
6
+ } from "./truncation.js";
7
+
8
+ export interface ToolResultError<TDetails = unknown> {
9
+ readonly code: string;
10
+ readonly message: string;
11
+ readonly details?: TDetails;
12
+ }
13
+
14
+ export type StandardToolDetails<TData = unknown, TErrorDetails = unknown> =
15
+ | {
16
+ readonly ok: true;
17
+ readonly data: TData;
18
+ readonly warnings?: readonly string[];
19
+ readonly truncation?: TruncatedText;
20
+ }
21
+ | {
22
+ readonly ok: false;
23
+ readonly error: ToolResultError<TErrorDetails>;
24
+ readonly warnings?: readonly string[];
25
+ readonly truncation?: TruncatedText;
26
+ };
27
+
28
+ export function textToolResult<TDetails>(
29
+ text: string,
30
+ details: TDetails,
31
+ ): AgentToolResult<TDetails> {
32
+ return {
33
+ content: [{ type: "text", text }],
34
+ details,
35
+ };
36
+ }
37
+
38
+ export function truncatedTextToolResult<
39
+ TDetails extends Record<string, unknown>,
40
+ >(
41
+ text: string,
42
+ details: TDetails,
43
+ options: TruncationOptions = {},
44
+ ): AgentToolResult<TDetails & { readonly truncation: TruncatedText }> {
45
+ const truncation = truncateText(text, options);
46
+ return textToolResult(truncation.text, { ...details, truncation });
47
+ }
48
+
49
+ export function okToolDetails<TData>(
50
+ data: TData,
51
+ options: {
52
+ readonly warnings?: readonly string[];
53
+ readonly truncation?: TruncatedText;
54
+ } = {},
55
+ ): StandardToolDetails<TData> {
56
+ return {
57
+ ok: true,
58
+ data,
59
+ ...(options.warnings === undefined ? {} : { warnings: options.warnings }),
60
+ ...(options.truncation === undefined
61
+ ? {}
62
+ : { truncation: options.truncation }),
63
+ };
64
+ }
65
+
66
+ export function errorToolDetails<TDetails = unknown>(
67
+ error: ToolResultError<TDetails>,
68
+ options: {
69
+ readonly warnings?: readonly string[];
70
+ readonly truncation?: TruncatedText;
71
+ } = {},
72
+ ): StandardToolDetails<never, TDetails> {
73
+ return {
74
+ ok: false,
75
+ error,
76
+ ...(options.warnings === undefined ? {} : { warnings: options.warnings }),
77
+ ...(options.truncation === undefined
78
+ ? {}
79
+ : { truncation: options.truncation }),
80
+ };
81
+ }
@@ -0,0 +1,150 @@
1
+ export const DEFAULT_MAX_OUTPUT_LINES = 80;
2
+ export const DEFAULT_MAX_OUTPUT_CHARS = 8_000;
3
+
4
+ export interface TruncationOptions {
5
+ readonly maxLines?: number;
6
+ readonly maxChars?: number;
7
+ }
8
+
9
+ export interface TruncatedText {
10
+ readonly text: string;
11
+ readonly truncated: boolean;
12
+ readonly originalChars: number;
13
+ readonly originalLines: number;
14
+ readonly omittedChars: number;
15
+ readonly omittedLines: number;
16
+ readonly maxChars: number;
17
+ readonly maxLines: number;
18
+ }
19
+
20
+ export function truncateText(
21
+ input: string,
22
+ options: TruncationOptions = {},
23
+ ): TruncatedText {
24
+ const maxLines = normalizePositiveInteger(
25
+ options.maxLines,
26
+ DEFAULT_MAX_OUTPUT_LINES,
27
+ "maxLines",
28
+ );
29
+ const maxChars = normalizePositiveInteger(
30
+ options.maxChars,
31
+ DEFAULT_MAX_OUTPUT_CHARS,
32
+ "maxChars",
33
+ );
34
+ const originalLines = countLines(input);
35
+ const originalChars = input.length;
36
+
37
+ let visible = input;
38
+ let truncated = false;
39
+
40
+ if (originalLines > maxLines) {
41
+ visible = input.split(/\r?\n/u).slice(0, maxLines).join("\n");
42
+ truncated = true;
43
+ }
44
+
45
+ if (visible.length > maxChars) {
46
+ visible = visible.slice(0, maxChars);
47
+ truncated = true;
48
+ }
49
+
50
+ if (!truncated) {
51
+ return {
52
+ text: input,
53
+ truncated: false,
54
+ originalChars,
55
+ originalLines,
56
+ omittedChars: 0,
57
+ omittedLines: 0,
58
+ maxChars,
59
+ maxLines,
60
+ };
61
+ }
62
+
63
+ const notice = buildTruncationNotice({
64
+ omittedChars: Math.max(0, originalChars - visible.length),
65
+ omittedLines: Math.max(0, originalLines - countLines(visible)),
66
+ });
67
+ const text = appendNoticeWithinBounds(visible, notice, maxLines, maxChars);
68
+
69
+ return {
70
+ text,
71
+ truncated: true,
72
+ originalChars,
73
+ originalLines,
74
+ omittedChars: Math.max(0, originalChars - visible.length),
75
+ omittedLines: Math.max(0, originalLines - countLines(visible)),
76
+ maxChars,
77
+ maxLines,
78
+ };
79
+ }
80
+
81
+ export function truncateLines(
82
+ lines: Iterable<string>,
83
+ options: TruncationOptions = {},
84
+ ): TruncatedText {
85
+ return truncateText(Array.from(lines).join("\n"), options);
86
+ }
87
+
88
+ export function countLines(text: string): number {
89
+ if (text.length === 0) {
90
+ return 0;
91
+ }
92
+ return text.split(/\r?\n/u).length;
93
+ }
94
+
95
+ function buildTruncationNotice({
96
+ omittedChars,
97
+ omittedLines,
98
+ }: {
99
+ readonly omittedChars: number;
100
+ readonly omittedLines: number;
101
+ }): string {
102
+ const parts: string[] = [];
103
+ if (omittedLines > 0) {
104
+ parts.push(`${omittedLines} line${omittedLines === 1 ? "" : "s"}`);
105
+ }
106
+ if (omittedChars > 0) {
107
+ parts.push(`${omittedChars} char${omittedChars === 1 ? "" : "s"}`);
108
+ }
109
+ return `… [truncated${parts.length > 0 ? `: ${parts.join(", ")} omitted` : ""}]`;
110
+ }
111
+
112
+ function appendNoticeWithinBounds(
113
+ text: string,
114
+ notice: string,
115
+ maxLines: number,
116
+ maxChars: number,
117
+ ): string {
118
+ if (maxChars <= 1) {
119
+ return "…".slice(0, maxChars);
120
+ }
121
+
122
+ const currentLines = countLines(text);
123
+ const separator = currentLines > 0 && currentLines < maxLines ? "\n" : " ";
124
+ const suffix = `${separator}${notice}`;
125
+
126
+ if (suffix.length >= maxChars) {
127
+ return notice.slice(0, maxChars);
128
+ }
129
+
130
+ const prefixBudget = maxChars - suffix.length;
131
+ const prefix = text.slice(0, prefixBudget).trimEnd();
132
+ if (prefix.length === 0) {
133
+ return notice.slice(0, maxChars);
134
+ }
135
+ return `${prefix}${suffix}`;
136
+ }
137
+
138
+ function normalizePositiveInteger(
139
+ value: number | undefined,
140
+ fallback: number,
141
+ name: string,
142
+ ): number {
143
+ if (value === undefined) {
144
+ return fallback;
145
+ }
146
+ if (!Number.isInteger(value) || value < 1) {
147
+ throw new RangeError(`${name} must be a positive integer`);
148
+ }
149
+ return value;
150
+ }