@danypops/papyrus 0.19.0 → 0.19.1
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
CHANGED
|
@@ -162,6 +162,8 @@ Rounds are a dedicated append-only child table (mirroring Task history's own sha
|
|
|
162
162
|
|
|
163
163
|
Blocking is real: `tasks.complete` is refused while any `active` Discussion has a `blocks` edge to that Task. A `deferred` Discussion does not block -- "we will get back to this" is distinct from "resolved."
|
|
164
164
|
|
|
165
|
+
Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, not just the shared Doc status glyph), open a scrollable transcript, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
|
|
166
|
+
|
|
165
167
|
```bash
|
|
166
168
|
papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
|
|
167
169
|
papyrus discuss reply <discussion-id> --actor bob --content "I think so, here's why..." --json
|
|
@@ -36,6 +36,19 @@ export const SKILL_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
|
36
36
|
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Keyed by extra.discussion.state, not the shared Doc status column -- a settled Discussion's
|
|
41
|
+
* doc.status becomes "archived", but a deferred one stays "active" at the doc level (see
|
|
42
|
+
* domain/discussion.ts's header comment). Reusing DOC_STATUS_PRESENTATION here would render
|
|
43
|
+
* "deferred" and "active" Discussions with the identical glyph, silently losing the one piece
|
|
44
|
+
* of state this feature exists to distinguish.
|
|
45
|
+
*/
|
|
46
|
+
export const DISCUSSION_STATE_PRESENTATION: Record<string, StatusPresentation> = {
|
|
47
|
+
active: { label: "active", glyph: "●", color: "accent" },
|
|
48
|
+
deferred: { label: "deferred", glyph: "⏸", color: "warning" },
|
|
49
|
+
settled: { label: "settled", glyph: "✓", color: "success" },
|
|
50
|
+
};
|
|
51
|
+
|
|
39
52
|
/** Rule severity gets its own color independent of status -- block is the loudest, info the quietest. */
|
|
40
53
|
export const RULE_SEVERITY_PRESENTATION: Record<string, ThemeColor> = {
|
|
41
54
|
block: "error",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discuss.ts — /discuss interactive panel.
|
|
3
|
+
* Reuses the generic artifact browser (artifact-browser.ts), same as docs.ts/rules.ts/notes.ts:
|
|
4
|
+
* a Discussion is a `doc` artifact, so the browser's list/filter/refresh/select-action loop
|
|
5
|
+
* applies unchanged. The one real wrinkle is that Discuss's meaningful lifecycle state
|
|
6
|
+
* (active/deferred/settled) lives in extra.discussion, not the shared doc status column the
|
|
7
|
+
* browser colors its row glyph by (see artifact-status-presentation.ts's DISCUSSION_STATE_PRESENTATION
|
|
8
|
+
* comment) -- so the real state is surfaced in rowMeta text instead, the same way rules.ts
|
|
9
|
+
* surfaces severity and notes.ts surfaces history count, both also not the row glyph.
|
|
10
|
+
*
|
|
11
|
+
* Creating a new Discussion is left to the agent (the discuss tool), matching docs.ts/rules.ts/
|
|
12
|
+
* skills.ts precedent -- Notes is the one kind with a human-facing creation command (/note),
|
|
13
|
+
* because Notes exists specifically as a human-authored inbox.
|
|
14
|
+
*/
|
|
15
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
17
|
+
import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
|
|
18
|
+
import { showArtifactBrowser } from "./artifact-browser.ts";
|
|
19
|
+
import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
20
|
+
import { discussionRoundCountOf, discussionStateOf, showDiscussionDetailView } from "./discussion-detail-view.ts";
|
|
21
|
+
import { callService } from "./service-client.ts";
|
|
22
|
+
|
|
23
|
+
const SOURCE = "discuss-tui";
|
|
24
|
+
const ACTOR = "human";
|
|
25
|
+
|
|
26
|
+
export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
|
|
27
|
+
const state = discussionStateOf(discussion);
|
|
28
|
+
const presentation = DISCUSSION_STATE_PRESENTATION[state];
|
|
29
|
+
const stateText = presentation ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`) : theme.fg("muted", "state unknown");
|
|
30
|
+
const rounds = discussionRoundCountOf(discussion);
|
|
31
|
+
return `${stateText} · ${rounds} round${rounds === 1 ? "" : "s"}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function discussionActions(discussion: Artifact): string[] {
|
|
35
|
+
const state = discussionStateOf(discussion);
|
|
36
|
+
if (state === "active") return ["Show transcript", "Reply", "Defer", "Settle", "Block a task", "Unblock a task"];
|
|
37
|
+
if (state === "deferred") return ["Show transcript", "Resume", "Settle"];
|
|
38
|
+
return ["Show transcript"]; // settled, or an unrecognized/corrupt state -- read-only either way
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function showDiscussions(ctx: ExtensionCommandContext): Promise<void> {
|
|
42
|
+
await showArtifactBrowser(ctx, {
|
|
43
|
+
kind: "doc",
|
|
44
|
+
title: "Discussions",
|
|
45
|
+
listOperation: "discuss.list",
|
|
46
|
+
statusOrder: ["draft", "active", "archived"],
|
|
47
|
+
presentation: DOC_STATUS_PRESENTATION,
|
|
48
|
+
rowMeta: discussionRowMeta,
|
|
49
|
+
actions: discussionActions,
|
|
50
|
+
handleAction: async (choice, discussion, commandCtx) => {
|
|
51
|
+
if (choice === "Show transcript") {
|
|
52
|
+
const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
|
|
53
|
+
await showDiscussionDetailView(commandCtx, result.discussion, result.rounds);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (choice === "Reply") {
|
|
57
|
+
const content = await commandCtx.ui.input("Reply:", "");
|
|
58
|
+
if (!content) return;
|
|
59
|
+
await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content, source: SOURCE });
|
|
60
|
+
commandCtx.ui.notify("Round added.", "info");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (choice === "Defer") {
|
|
64
|
+
const reason = await commandCtx.ui.input("Defer reason (optional):", "");
|
|
65
|
+
await callService("discuss.defer", { id: discussion.id, ...(reason ? { reason } : {}), actor: ACTOR, source: SOURCE });
|
|
66
|
+
commandCtx.ui.notify("Deferred.", "info");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (choice === "Resume") {
|
|
70
|
+
await callService("discuss.resume", { id: discussion.id, actor: ACTOR, source: SOURCE });
|
|
71
|
+
commandCtx.ui.notify("Resumed.", "info");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (choice === "Settle") {
|
|
75
|
+
const settlement = await commandCtx.ui.input("Settlement:", "");
|
|
76
|
+
if (!settlement) return;
|
|
77
|
+
await callService("discuss.settle", { id: discussion.id, settlement, actor: ACTOR, source: SOURCE });
|
|
78
|
+
commandCtx.ui.notify("Settled.", "info");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (choice === "Block a task") {
|
|
82
|
+
const taskId = await commandCtx.ui.input("Task artifact id to block:", "");
|
|
83
|
+
if (!taskId) return;
|
|
84
|
+
await callService("discuss.block", { id: discussion.id, task_id: taskId, actor: ACTOR, source: SOURCE });
|
|
85
|
+
commandCtx.ui.notify(`${discussion.id} now blocks ${taskId}`, "info");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (choice === "Unblock a task") {
|
|
89
|
+
const taskId = await commandCtx.ui.input("Task artifact id to unblock:", "");
|
|
90
|
+
if (!taskId) return;
|
|
91
|
+
const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", { id: discussion.id, task_id: taskId, actor: ACTOR, source: SOURCE });
|
|
92
|
+
commandCtx.ui.notify(result.unblocked ? `${discussion.id} no longer blocks ${taskId}` : "No such blocking relationship.", "info");
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discussion-detail-view.ts — the transcript view for a single Discussion.
|
|
3
|
+
*
|
|
4
|
+
* The generic artifact detail view (artifact-detail-view.ts) formats an artifact's own
|
|
5
|
+
* fields (title, body, extra as JSON, edges); it has no way to show a Discussion's rounds,
|
|
6
|
+
* since those live in a dedicated child table fetched separately (discuss.show / discuss.rounds),
|
|
7
|
+
* not in the artifact row itself. Tasks needed the same kind of dedicated view for the same
|
|
8
|
+
* underlying reason (task-detail-view.ts) -- this mirrors that scrolling-viewport idiom rather
|
|
9
|
+
* than inventing a new one.
|
|
10
|
+
*/
|
|
11
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
13
|
+
import {
|
|
14
|
+
ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
|
|
15
|
+
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
16
|
+
ARTIFACT_DETAIL_RESERVED_ROWS,
|
|
17
|
+
} from "../../src/constants.ts";
|
|
18
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
19
|
+
import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
|
|
20
|
+
import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
|
|
21
|
+
import { DISCUSSION_STATE_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
22
|
+
|
|
23
|
+
interface TranscriptLine {
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reads state defensively for display -- a corrupt/foreign extra.discussion shape shows as "unknown" rather than crashing the whole panel over one bad row. */
|
|
28
|
+
export function discussionStateOf(discussion: Artifact): string {
|
|
29
|
+
try {
|
|
30
|
+
return readDiscussionExtra(discussion.extra).state;
|
|
31
|
+
} catch {
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function discussionRoundCountOf(discussion: Artifact): number {
|
|
37
|
+
try {
|
|
38
|
+
return readDiscussionExtra(discussion.extra).roundCount;
|
|
39
|
+
} catch {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class DiscussionTranscriptViewport {
|
|
45
|
+
private offsetY = 0;
|
|
46
|
+
private renderedWidth = 0;
|
|
47
|
+
private lines: TranscriptLine[] = [];
|
|
48
|
+
private readonly visibleLines: number;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly tui: TUI,
|
|
52
|
+
private readonly activeTheme: ActiveTheme,
|
|
53
|
+
private readonly discussion: Artifact,
|
|
54
|
+
private readonly rounds: DiscussionRound[],
|
|
55
|
+
private readonly close: () => void,
|
|
56
|
+
) {
|
|
57
|
+
this.visibleLines = Math.max(
|
|
58
|
+
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
59
|
+
Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
invalidate(): void { this.renderedWidth = 0; }
|
|
64
|
+
|
|
65
|
+
render(width: number): string[] {
|
|
66
|
+
const contentWidth = Math.max(1, width - 2);
|
|
67
|
+
this.buildLines(contentWidth);
|
|
68
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
69
|
+
const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
|
|
70
|
+
const theme = this.activeTheme();
|
|
71
|
+
const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
|
|
72
|
+
const footer = [
|
|
73
|
+
this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
|
|
74
|
+
"Esc back",
|
|
75
|
+
].filter(Boolean).join(" · ");
|
|
76
|
+
return [
|
|
77
|
+
border,
|
|
78
|
+
truncateToWidth(theme.fg("accent", theme.bold("Discussion transcript")), width, ""),
|
|
79
|
+
border,
|
|
80
|
+
...this.lines.slice(this.offsetY, end).map((line) => truncateToWidth(` ${line.text}`, width, "")),
|
|
81
|
+
truncateToWidth(theme.fg("dim", footer), width, ""),
|
|
82
|
+
border,
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
handleInput(data: string): void {
|
|
87
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
88
|
+
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
89
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
|
|
90
|
+
else if (matchesKey(data, "pageDown")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + this.visibleLines);
|
|
91
|
+
else if (matchesKey(data, "pageUp")) this.offsetY = Math.max(0, this.offsetY - this.visibleLines);
|
|
92
|
+
else return;
|
|
93
|
+
this.tui.requestRender();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private buildLines(width: number): void {
|
|
97
|
+
if (this.renderedWidth === width) return;
|
|
98
|
+
this.renderedWidth = width;
|
|
99
|
+
const theme = this.activeTheme();
|
|
100
|
+
const extra = (() => { try { return readDiscussionExtra(this.discussion.extra); } catch { return undefined; } })();
|
|
101
|
+
const presentation = extra ? DISCUSSION_STATE_PRESENTATION[extra.state] : undefined;
|
|
102
|
+
const stateLine = presentation
|
|
103
|
+
? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`)
|
|
104
|
+
: theme.fg("muted", "state unknown");
|
|
105
|
+
const header: TranscriptLine[] = [
|
|
106
|
+
{ text: theme.bold(this.discussion.title) },
|
|
107
|
+
{ text: `${stateLine}${theme.fg("dim", ` · ${this.discussion.id}`)}` },
|
|
108
|
+
...(extra?.deferredReason ? [{ text: theme.fg("muted", `Deferred: ${extra.deferredReason}`) }] : []),
|
|
109
|
+
...(extra?.settlement ? [{ text: theme.fg("success", `Settled: ${extra.settlement}`) }] : []),
|
|
110
|
+
{ text: "" },
|
|
111
|
+
];
|
|
112
|
+
const transcript: TranscriptLine[] = this.rounds.flatMap((round, index) => {
|
|
113
|
+
const roundHeader = theme.fg("accent", `[round ${round.roundNumber}] `) + theme.bold(round.actor) + theme.fg("dim", ` · ${round.occurredAt}`);
|
|
114
|
+
const body = renderMarkdownBody(round.content, width - 2, this.activeTheme).map((line) => ({ text: ` ${line}` }));
|
|
115
|
+
return [{ text: roundHeader }, ...body, ...(index < this.rounds.length - 1 ? [{ text: "" }] : [])];
|
|
116
|
+
});
|
|
117
|
+
this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
|
|
118
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function showDiscussionDetailView(ctx: ExtensionCommandContext, discussion: Artifact, rounds: DiscussionRound[]): Promise<void> {
|
|
123
|
+
if (ctx.mode !== "tui") {
|
|
124
|
+
const lines = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`);
|
|
125
|
+
ctx.ui.notify([discussion.title, ...lines].join("\n"), "info");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
129
|
+
new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done));
|
|
130
|
+
}
|
package/extension/src/index.ts
CHANGED
|
@@ -416,12 +416,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
416
416
|
// ── Interactive artifact browsers ──────────────────────────────────
|
|
417
417
|
|
|
418
418
|
// Lazy imports keep TUI components out of non-interactive startup paths.
|
|
419
|
-
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule] = await Promise.all([
|
|
419
|
+
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, discussModule] = await Promise.all([
|
|
420
420
|
import("./tasks.ts"),
|
|
421
421
|
import("./docs.ts"),
|
|
422
422
|
import("./notes.ts"),
|
|
423
423
|
import("./rules.ts"),
|
|
424
424
|
import("./skills.ts"),
|
|
425
|
+
import("./discuss.ts"),
|
|
425
426
|
]);
|
|
426
427
|
let overlay: TaskOverlay | undefined;
|
|
427
428
|
|
|
@@ -454,6 +455,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
454
455
|
description: "Browse and invoke Papyrus skills and templates (interactive)",
|
|
455
456
|
handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
|
|
456
457
|
});
|
|
458
|
+
pi.registerCommand("discuss", {
|
|
459
|
+
description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
|
|
460
|
+
handler: async (_args, ctx) => { await discussModule.showDiscussions(ctx); },
|
|
461
|
+
});
|
|
457
462
|
pi.registerCommand("context", {
|
|
458
463
|
description: "Structured, per-segment breakdown of the context window: real usage against the model's window, drilling into Papyrus Rules and the Pi-native skill catalog",
|
|
459
464
|
handler: async (_args, ctx) => {
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* append-only rounds (via DiscussionRoundStore). See domain/discussion.ts for the full
|
|
4
4
|
* design rationale.
|
|
5
5
|
*/
|
|
6
|
-
import { DISCUSSION_MAX_ROUNDS } from "./constants.ts";
|
|
6
|
+
import { DISCUSSION_LIST_DEFAULT_LIMIT, DISCUSSION_LIST_MAX_LIMIT, DISCUSSION_MAX_ROUNDS } from "./constants.ts";
|
|
7
7
|
import {
|
|
8
8
|
DISCUSSION_SUBTYPE,
|
|
9
9
|
isDiscussionArtifact,
|
|
@@ -29,7 +29,6 @@ export interface OpenDiscussionInput {
|
|
|
29
29
|
body?: string;
|
|
30
30
|
labels?: string[];
|
|
31
31
|
blocksTaskIds?: string[];
|
|
32
|
-
projectRoot?: string;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
export interface DiscussionAndRounds {
|
|
@@ -150,7 +149,11 @@ export class Discussions {
|
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
list(filter: { state?: string; limit?: number } = {}): Artifact[] {
|
|
153
|
-
|
|
152
|
+
// DISCUSSION_LIST_MAX_LIMIT/DEFAULT_LIMIT exist specifically so an unqualified discuss.list
|
|
153
|
+
// (limit omitted) can never fall through to queryArtifacts' own unbounded default -- the same
|
|
154
|
+
// class of gap notes.ts's noteListInput comment documents fixing for Notes.
|
|
155
|
+
const limit = Math.min(DISCUSSION_LIST_MAX_LIMIT, Math.max(1, Math.floor(filter.limit ?? DISCUSSION_LIST_DEFAULT_LIMIT)));
|
|
156
|
+
const rows = this.artifacts.query({ kind: "doc", subtype: DISCUSSION_SUBTYPE, limit });
|
|
154
157
|
if (!filter.state) return rows;
|
|
155
158
|
return rows.filter((row) => {
|
|
156
159
|
try { return this.extra(row).state === filter.state; } catch { return false; }
|