pi-do-always 0.13.0 → 0.16.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.
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
import { execFile } from "node:child_process";
|
|
37
37
|
import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
38
38
|
import { join, relative } from "node:path";
|
|
39
|
-
import type { AgentEndEvent, ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import type { AgentEndEvent, ExtensionAPI, ExtensionContext, MessageEndEvent, Theme } from "@earendil-works/pi-coding-agent";
|
|
40
40
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
41
41
|
import {
|
|
42
42
|
type KeyId,
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
COMMIT_SELECT_MAX,
|
|
56
56
|
DEFAULT_SHORTCUT,
|
|
57
57
|
DEFAULT_TASKS,
|
|
58
|
+
PLAN_OUTPUT_INSTRUCTION,
|
|
58
59
|
assistantText,
|
|
59
60
|
buildTableRows,
|
|
60
61
|
chainAdd,
|
|
@@ -67,9 +68,11 @@ import {
|
|
|
67
68
|
evaluateWhen,
|
|
68
69
|
formatChainSequence,
|
|
69
70
|
formatList,
|
|
71
|
+
formatPlanExecutionPrompt,
|
|
70
72
|
formatSelectedCommits,
|
|
71
73
|
groupCommitsByDate,
|
|
72
74
|
groupTasksByCategory,
|
|
75
|
+
isPlanTask,
|
|
73
76
|
isTaskVisible,
|
|
74
77
|
isValidKeyId,
|
|
75
78
|
mergeTasks,
|
|
@@ -77,9 +80,18 @@ import {
|
|
|
77
80
|
parseConfigRegexpValueForKey,
|
|
78
81
|
parseCommitSubject,
|
|
79
82
|
parseGitLogOutput,
|
|
83
|
+
parsePlanProposal,
|
|
80
84
|
parseStatusPorcelain,
|
|
81
85
|
parseStatusStagedUnstaged,
|
|
82
86
|
orderTasksByCategory,
|
|
87
|
+
planBlockDiagnostics,
|
|
88
|
+
planItemKey,
|
|
89
|
+
planSelectAll,
|
|
90
|
+
planSelectionClear,
|
|
91
|
+
planSelectedItems,
|
|
92
|
+
planTierState,
|
|
93
|
+
planToggleItem,
|
|
94
|
+
planToggleTier,
|
|
83
95
|
reportAbandonedFooter,
|
|
84
96
|
reportFooter,
|
|
85
97
|
reportHeader,
|
|
@@ -91,12 +103,16 @@ import {
|
|
|
91
103
|
resolveTask,
|
|
92
104
|
shouldAutoRun,
|
|
93
105
|
stepSummary,
|
|
106
|
+
stripPlanBlocks,
|
|
94
107
|
toPromptContext,
|
|
95
108
|
validateChain,
|
|
96
109
|
type ChainStepOutcome,
|
|
97
110
|
type CommitInfo,
|
|
98
111
|
type DateGroup,
|
|
99
112
|
type DoAlwaysTask,
|
|
113
|
+
type PlanProposal,
|
|
114
|
+
type PlanSelection,
|
|
115
|
+
type PlanSelectionEntry,
|
|
100
116
|
type SelectedCommit,
|
|
101
117
|
type TaskContext,
|
|
102
118
|
type TableRow,
|
|
@@ -149,6 +165,10 @@ function loadConfig(
|
|
|
149
165
|
shortcut: string | null;
|
|
150
166
|
/** Whether chain runs write a Markdown report file (default true). */
|
|
151
167
|
report: boolean;
|
|
168
|
+
/** Whether plan questionnaires are offered (default true). */
|
|
169
|
+
questionnaire: boolean;
|
|
170
|
+
/** Whether the raw plan block is hidden from the transcript (default true). */
|
|
171
|
+
hidePlan: boolean;
|
|
152
172
|
} {
|
|
153
173
|
const globalPath = join(getAgentDir(), "do-always.json");
|
|
154
174
|
const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
|
|
@@ -158,10 +178,10 @@ function loadConfig(
|
|
|
158
178
|
|
|
159
179
|
const global = globalRaw !== null
|
|
160
180
|
? parseConfig(globalRaw, globalPath, onError)
|
|
161
|
-
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
181
|
+
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
|
|
162
182
|
const project = projectRaw !== null
|
|
163
183
|
? parseConfig(projectRaw, projectPath, onError)
|
|
164
|
-
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
184
|
+
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
|
|
165
185
|
|
|
166
186
|
// The project file's merge mode wins; otherwise the global value; otherwise
|
|
167
187
|
// override (the historical behavior), so existing configs are unaffected.
|
|
@@ -175,6 +195,10 @@ function loadConfig(
|
|
|
175
195
|
// The project file's value wins; otherwise the global value; otherwise
|
|
176
196
|
// reports are on.
|
|
177
197
|
report: project.report ?? global.report ?? true,
|
|
198
|
+
// Same precedence: project, then global, then on.
|
|
199
|
+
questionnaire: project.questionnaire ?? global.questionnaire ?? true,
|
|
200
|
+
// Same precedence: project, then global, then on (the block is hidden).
|
|
201
|
+
hidePlan: project.hidePlan ?? global.hidePlan ?? true,
|
|
178
202
|
};
|
|
179
203
|
}
|
|
180
204
|
|
|
@@ -950,10 +974,30 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
950
974
|
// flag would post a spurious summary for the next unrelated turn.
|
|
951
975
|
let pendingSummaryTask: string | null = null;
|
|
952
976
|
let pendingSummaryTimer: NodeJS.Timeout | null = null;
|
|
977
|
+
// The completed auto-run task's reply, captured at agent_end and offered
|
|
978
|
+
// at agent_settled (the session is fully idle then, so the confirm
|
|
979
|
+
// follow-up cannot race queued continuations). Discarded on agent_start
|
|
980
|
+
// — a new run started, so the proposal is stale — and on session start.
|
|
981
|
+
let pendingProposal: { taskName: string; text: string } | null = null;
|
|
982
|
+
// The last offered plan proposal (parseable, questionnaire enabled), kept
|
|
983
|
+
// so `/do-always replan` can re-open the questionnaire after a
|
|
984
|
+
// withdrawal. Cleared on confirm (executed) and on session start.
|
|
985
|
+
let lastProposal: { taskName: string; text: string } | null = null;
|
|
986
|
+
// The raw (unstripped) reply text of the pending auto-run task, captured
|
|
987
|
+
// at message_end before its plan block is stripped from the transcript
|
|
988
|
+
// (by the time agent_end fires, the message there is already stripped).
|
|
989
|
+
// Cleared on agent_start (a new run makes it stale), agent_end (consumed
|
|
990
|
+
// or discarded), and session start.
|
|
991
|
+
let pendingPlanRaw: string | null = null;
|
|
953
992
|
|
|
954
993
|
/** Clear the auto-run summary flag and its grace timer (session start). */
|
|
955
994
|
function resetPendingSummary(): void {
|
|
956
995
|
pendingSummaryTask = null;
|
|
996
|
+
// A completed auto-run's captured reply is only offered at the
|
|
997
|
+
// settle that follows its own agent_end — a new run invalidates it.
|
|
998
|
+
pendingProposal = null;
|
|
999
|
+
lastProposal = null;
|
|
1000
|
+
pendingPlanRaw = null;
|
|
957
1001
|
if (pendingSummaryTimer) {
|
|
958
1002
|
clearTimeout(pendingSummaryTimer);
|
|
959
1003
|
pendingSummaryTimer = null;
|
|
@@ -962,6 +1006,410 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
962
1006
|
// Whether chain runs write a Markdown report file (config `report`,
|
|
963
1007
|
// default true). Refreshed whenever the config is (re)loaded.
|
|
964
1008
|
let reportEnabled = true;
|
|
1009
|
+
// Whether completed auto-run tasks whose reply carries a "plan" block
|
|
1010
|
+
// offer the selection questionnaire (config `questionnaire`, default
|
|
1011
|
+
// true). Refreshed whenever the config is (re)loaded.
|
|
1012
|
+
let questionnaireEnabled = true;
|
|
1013
|
+
// Whether the raw "plan" block is stripped from the transcript after a
|
|
1014
|
+
// completed auto-run task (config `hidePlan`, default true). Refreshed
|
|
1015
|
+
// whenever the config is (re)loaded.
|
|
1016
|
+
let hidePlanEnabled = true;
|
|
1017
|
+
|
|
1018
|
+
/** The result of the plan questionnaire: the confirmed selection, or a withdrawal. */
|
|
1019
|
+
type PlanQuestionnaireResult =
|
|
1020
|
+
| { kind: "confirm"; items: PlanSelectionEntry[] }
|
|
1021
|
+
| { kind: "withdraw" };
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Render a task's prompt for injection or preview. Plan-category tasks get
|
|
1025
|
+
* PLAN_OUTPUT_INSTRUCTION appended (once) so the reply carries the
|
|
1026
|
+
* machine-readable "plan" block the questionnaire parses — but only when
|
|
1027
|
+
* the questionnaire is actually offered for this task, otherwise the agent
|
|
1028
|
+
* would emit a block nobody reads. A prompt that already mentions the plan
|
|
1029
|
+
* fence keeps its own contract; chain-step prompts are rendered with the
|
|
1030
|
+
* plain renderPrompt and never get it.
|
|
1031
|
+
*/
|
|
1032
|
+
function renderTaskPrompt(task: DoAlwaysTask, ctx: Record<string, string>): string {
|
|
1033
|
+
const base = renderPrompt(task.prompt, ctx);
|
|
1034
|
+
const enabled = task.questionnaire ?? questionnaireEnabled;
|
|
1035
|
+
if (isPlanTask(task) && enabled && !base.includes("```plan")) {
|
|
1036
|
+
return `${base}\n\n${PLAN_OUTPUT_INSTRUCTION}`;
|
|
1037
|
+
}
|
|
1038
|
+
return base;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Offer a completed auto-run task's reply. When it carries a parseable
|
|
1043
|
+
* "plan" block and the questionnaire is enabled, the TUI shows the tier/
|
|
1044
|
+
* item questionnaire: confirming sends the selection as an execution
|
|
1045
|
+
* follow-up, Esc withdraws (nothing happens). Everything else — disabled,
|
|
1046
|
+
* non-TUI, or no plan block — falls back to the plain summary
|
|
1047
|
+
* notification.
|
|
1048
|
+
*/
|
|
1049
|
+
async function offerPlanProposal(
|
|
1050
|
+
captured: { taskName: string; text: string },
|
|
1051
|
+
ctx: ExtensionContext | null,
|
|
1052
|
+
): Promise<void> {
|
|
1053
|
+
const proposal = parsePlanProposal(captured.text);
|
|
1054
|
+
if (!ctx) return;
|
|
1055
|
+
const task = tasks.find((t) => t.name === captured.taskName);
|
|
1056
|
+
const enabled = task?.questionnaire ?? questionnaireEnabled;
|
|
1057
|
+
// The prompt asked for a plan block only for Plan tasks with the
|
|
1058
|
+
// questionnaire enabled (renderTaskPrompt's gate) — only then is a
|
|
1059
|
+
// missing or invalid block a contract violation worth explaining; for
|
|
1060
|
+
// a non-Plan auto-run task its absence is the expected outcome.
|
|
1061
|
+
const blockExpected = task !== undefined && isPlanTask(task) && enabled;
|
|
1062
|
+
if (!proposal || !enabled) {
|
|
1063
|
+
const base = `do-always: ${stepSummary("completed", captured.taskName, 0, 0)}`;
|
|
1064
|
+
if (!blockExpected) {
|
|
1065
|
+
// Questionnaire disabled, or a non-Plan auto-run task: the
|
|
1066
|
+
// prompt never asked for a plan block, so its absence is not a
|
|
1067
|
+
// contract violation — plain summary.
|
|
1068
|
+
ctx.ui.notify(base, "info");
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
// The prompt asked for a plan block but none was usable — say why,
|
|
1072
|
+
// so the fallback to a plain summary is not a mystery.
|
|
1073
|
+
const diag = planBlockDiagnostics(captured.text);
|
|
1074
|
+
if (diag.kind === "none") {
|
|
1075
|
+
ctx.ui.notify(`${base} — reply had no plan block, so no questionnaire was offered`, "warning");
|
|
1076
|
+
} else if (diag.kind === "malformed") {
|
|
1077
|
+
ctx.ui.notify(`${base} — plan block was not valid JSON (${diag.detail}); no questionnaire offered`, "warning");
|
|
1078
|
+
} else {
|
|
1079
|
+
// "empty": the agent proposed no action items — a legitimate
|
|
1080
|
+
// outcome, not a contract violation.
|
|
1081
|
+
ctx.ui.notify(`${base} — no action items proposed`, "info");
|
|
1082
|
+
}
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
// Remember the last offered proposal so /do-always replan can re-open
|
|
1086
|
+
// it after a withdrawal.
|
|
1087
|
+
lastProposal = captured;
|
|
1088
|
+
if (ctx.mode !== "tui") {
|
|
1089
|
+
// Non-TUI: list the proposed items so the user can reply with a
|
|
1090
|
+
// selection; nothing is sent automatically.
|
|
1091
|
+
const all = planSelectedItems(proposal, planSelectAll(proposal, planSelectionClear()));
|
|
1092
|
+
const list = all.map(({ tier, item }, i) => ` ${i + 1}. [${tier.id}] ${item.title}`).join("\n");
|
|
1093
|
+
ctx.ui.notify(
|
|
1094
|
+
`do-always: "${captured.taskName}" proposed ${all.length} action item(s):\n${list}\nReply with the item numbers to execute (or do nothing to withdraw).`,
|
|
1095
|
+
"info",
|
|
1096
|
+
);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const result = await showPlanQuestionnaire(ctx, proposal, captured.taskName);
|
|
1100
|
+
if (result.kind === "confirm") {
|
|
1101
|
+
lastProposal = null; // executed — nothing left to re-offer
|
|
1102
|
+
const prompt = formatPlanExecutionPrompt(result.items, captured.taskName);
|
|
1103
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
1104
|
+
ctx.ui.notify(
|
|
1105
|
+
`do-always: executing ${result.items.length} selected item(s) from the "${captured.taskName}" plan`,
|
|
1106
|
+
"info",
|
|
1107
|
+
);
|
|
1108
|
+
} else {
|
|
1109
|
+
ctx.ui.notify(`do-always: plan withdrawn — no action taken (re-open with /do-always replan)`, "info");
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Plan questionnaire: shown after a completed auto-run task whose reply
|
|
1115
|
+
* carries a parseable "plan" block. The summary line up top, then the tiers
|
|
1116
|
+
* with their action items. Selecting a tier row toggles the whole tier (all
|
|
1117
|
+
* its items); selecting an item row toggles just that item. The pinned
|
|
1118
|
+
* Confirm row sends the selection as an execution follow-up; Esc withdraws
|
|
1119
|
+
* (nothing happens).
|
|
1120
|
+
*/
|
|
1121
|
+
function showPlanQuestionnaire(
|
|
1122
|
+
ctx: ExtensionContext,
|
|
1123
|
+
proposal: PlanProposal,
|
|
1124
|
+
taskName: string,
|
|
1125
|
+
): Promise<PlanQuestionnaireResult> {
|
|
1126
|
+
return new Promise<PlanQuestionnaireResult>((resolve) => {
|
|
1127
|
+
ctx.ui.custom<PlanQuestionnaireResult>((tui, theme, _kb, done) => {
|
|
1128
|
+
const kb = getKeybindings();
|
|
1129
|
+
let settled = false;
|
|
1130
|
+
let selection: PlanSelection = planSelectionClear();
|
|
1131
|
+
|
|
1132
|
+
// Flat cursor rows: a tier header row, its item rows, and the
|
|
1133
|
+
// pinned Confirm row last.
|
|
1134
|
+
type Row =
|
|
1135
|
+
| { kind: "tier"; index: number }
|
|
1136
|
+
| { kind: "item"; tier: number; index: number }
|
|
1137
|
+
| { kind: "confirm" };
|
|
1138
|
+
const rows: Row[] = [];
|
|
1139
|
+
proposal.tiers.forEach((tier, ti) => {
|
|
1140
|
+
rows.push({ kind: "tier", index: ti });
|
|
1141
|
+
tier.items.forEach((_, ii) => rows.push({ kind: "item", tier: ti, index: ii }));
|
|
1142
|
+
});
|
|
1143
|
+
rows.push({ kind: "confirm" });
|
|
1144
|
+
// The scrollable part: everything but the pinned Confirm row.
|
|
1145
|
+
// (Cast — slice() does not narrow the row union; the last row
|
|
1146
|
+
// is always the confirm row pushed above.)
|
|
1147
|
+
type ListRow = Exclude<Row, { kind: "confirm" }>;
|
|
1148
|
+
const listRows = rows.slice(0, -1) as ListRow[];
|
|
1149
|
+
let cursor = 0;
|
|
1150
|
+
const MAX_VISIBLE = 12; // tier/item rows visible in the scroll window
|
|
1151
|
+
// Per-item notes (item key → note), added with `e` on an item
|
|
1152
|
+
// row and carried into the execution prompt on confirm.
|
|
1153
|
+
const notes = new Map<string, string>();
|
|
1154
|
+
let noteKey: string | null = null; // the item being annotated
|
|
1155
|
+
let noteDraft = "";
|
|
1156
|
+
|
|
1157
|
+
function finish(result: PlanQuestionnaireResult) {
|
|
1158
|
+
if (settled) return;
|
|
1159
|
+
settled = true;
|
|
1160
|
+
done(result);
|
|
1161
|
+
resolve(result);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function tierCount(ti: number): string {
|
|
1165
|
+
const tier = proposal.tiers[ti];
|
|
1166
|
+
let n = 0;
|
|
1167
|
+
tier.items.forEach((_, ii) => {
|
|
1168
|
+
if (selection.has(planItemKey(ti, ii))) n++;
|
|
1169
|
+
});
|
|
1170
|
+
return `${n}/${tier.items.length}`;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Line map for mouse hit-testing (rebuilt on every render).
|
|
1174
|
+
let rowLine = new Map<number, Row>();
|
|
1175
|
+
|
|
1176
|
+
function render(width: number): string[] {
|
|
1177
|
+
rowLine = new Map();
|
|
1178
|
+
const lines: string[] = [];
|
|
1179
|
+
lines.push(
|
|
1180
|
+
theme.fg("accent", theme.bold(truncateToWidth(` Plan proposal — ${taskName}`, width - 2, ""))),
|
|
1181
|
+
);
|
|
1182
|
+
if (proposal.summary) {
|
|
1183
|
+
lines.push(theme.fg("muted", truncateToWidth(` ${proposal.summary}`, width - 2, "…")));
|
|
1184
|
+
}
|
|
1185
|
+
lines.push("");
|
|
1186
|
+
// Scroll window over the tier/item rows (the Confirm row is
|
|
1187
|
+
// pinned below it). The window follows the cursor, clamped at
|
|
1188
|
+
// both edges — the same pattern as the task selector.
|
|
1189
|
+
const anchor = cursor < listRows.length ? cursor : listRows.length - 1;
|
|
1190
|
+
const winStart =
|
|
1191
|
+
listRows.length > MAX_VISIBLE
|
|
1192
|
+
? Math.max(0, Math.min(anchor + 1 - MAX_VISIBLE, listRows.length - MAX_VISIBLE))
|
|
1193
|
+
: 0;
|
|
1194
|
+
const winEnd = Math.min(winStart + MAX_VISIBLE, listRows.length);
|
|
1195
|
+
for (let i = winStart; i < winEnd; i++) {
|
|
1196
|
+
const row = listRows[i];
|
|
1197
|
+
if (row.kind === "tier") {
|
|
1198
|
+
const ti = row.index;
|
|
1199
|
+
const tier = proposal.tiers[ti];
|
|
1200
|
+
const state = planTierState(proposal, ti, selection);
|
|
1201
|
+
const glyph = state === "all" ? "✓" : state === "partial" ? "◐" : "·";
|
|
1202
|
+
const color = state === "all" ? "success" : state === "partial" ? "warning" : "dim";
|
|
1203
|
+
const headerText = ` [${glyph}] ${tier.id} — ${tier.label} (${tierCount(ti)})`;
|
|
1204
|
+
if (cursor === i) {
|
|
1205
|
+
lines.push(theme.bg("selectedBg", theme.bold(headerText)));
|
|
1206
|
+
} else {
|
|
1207
|
+
lines.push(` ${theme.fg(color, `[${glyph}]`)} ${tier.id} — ${tier.label} (${tierCount(ti)})`);
|
|
1208
|
+
}
|
|
1209
|
+
rowLine.set(lines.length - 1, row);
|
|
1210
|
+
} else {
|
|
1211
|
+
const ti = row.tier;
|
|
1212
|
+
const ii = row.index;
|
|
1213
|
+
const key = planItemKey(ti, ii);
|
|
1214
|
+
const item = proposal.tiers[ti].items[ii];
|
|
1215
|
+
const mark = selection.has(key) ? theme.fg("success", "✓") : theme.fg("dim", "·");
|
|
1216
|
+
const note = notes.get(key);
|
|
1217
|
+
const title = truncateToWidth(
|
|
1218
|
+
note ? `${item.title} ✎ ${note}` : item.title,
|
|
1219
|
+
Math.max(10, width - 8),
|
|
1220
|
+
"…",
|
|
1221
|
+
);
|
|
1222
|
+
const rowText = ` ${mark} ${title}`;
|
|
1223
|
+
if (cursor === i) {
|
|
1224
|
+
lines.push(theme.bg("selectedBg", theme.bold(rowText)));
|
|
1225
|
+
} else {
|
|
1226
|
+
lines.push(rowText);
|
|
1227
|
+
}
|
|
1228
|
+
rowLine.set(lines.length - 1, row);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// Scroll position marker (only when the list overflows the window).
|
|
1232
|
+
if (listRows.length > MAX_VISIBLE) {
|
|
1233
|
+
lines.push(theme.fg("dim", ` (${anchor + 1}/${listRows.length})`));
|
|
1234
|
+
}
|
|
1235
|
+
// Pinned Confirm row (always visible, outside the scroll window).
|
|
1236
|
+
lines.push("");
|
|
1237
|
+
lines.push(theme.fg("dim", ` ${"─".repeat(Math.max(1, width - 4))}`));
|
|
1238
|
+
const totalItems = proposal.tiers.reduce((n, t) => n + t.items.length, 0);
|
|
1239
|
+
const confirmLabel = `Confirm (${selection.size}/${totalItems})`;
|
|
1240
|
+
if (cursor === rows.length - 1) {
|
|
1241
|
+
lines.push(theme.bg("selectedBg", theme.bold(`${theme.fg("accent", "►")} ${confirmLabel}`)));
|
|
1242
|
+
} else if (selection.size > 0) {
|
|
1243
|
+
lines.push(theme.fg("accent", ` ${confirmLabel}`));
|
|
1244
|
+
} else {
|
|
1245
|
+
lines.push(theme.fg("dim", ` ${confirmLabel}`));
|
|
1246
|
+
}
|
|
1247
|
+
rowLine.set(lines.length - 1, { kind: "confirm" });
|
|
1248
|
+
if (noteKey !== null) {
|
|
1249
|
+
// Note editor: replaces the key hint while active.
|
|
1250
|
+
lines.push(theme.fg("accent", truncateToWidth(` note> ${noteDraft}`, width - 2, "")));
|
|
1251
|
+
lines.push(theme.fg("dim", " enter save note • esc cancel"));
|
|
1252
|
+
} else {
|
|
1253
|
+
lines.push(
|
|
1254
|
+
theme.fg(
|
|
1255
|
+
"dim",
|
|
1256
|
+
truncateToWidth(
|
|
1257
|
+
` space/⏎ toggle • a all • ctrl+u clear • e note • ⏎ confirm • esc withdraw`,
|
|
1258
|
+
width - 2,
|
|
1259
|
+
"",
|
|
1260
|
+
),
|
|
1261
|
+
),
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
return lines;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function toggleAt(row: Row) {
|
|
1268
|
+
if (row.kind === "tier") {
|
|
1269
|
+
selection = planToggleTier(proposal, row.index, selection).selection;
|
|
1270
|
+
} else if (row.kind === "item") {
|
|
1271
|
+
selection = planToggleItem(selection, planItemKey(row.tier, row.index));
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function confirmIfPossible(): boolean {
|
|
1276
|
+
const items = planSelectedItems(proposal, selection, notes);
|
|
1277
|
+
if (items.length === 0) {
|
|
1278
|
+
ctx.ui.notify("do-always: nothing selected — pick a tier or item first (or esc to withdraw)", "info");
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|
|
1281
|
+
finish({ kind: "confirm", items });
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function handleInput(data: string) {
|
|
1286
|
+
if (settled) return;
|
|
1287
|
+
// Note mode: capture the note for the item under the cursor.
|
|
1288
|
+
// Enter saves (an empty note clears it), Esc cancels (the
|
|
1289
|
+
// previous note, if any, is kept).
|
|
1290
|
+
if (noteKey !== null) {
|
|
1291
|
+
if (matchesKey(data, "enter") || kb.matches(data, "tui.select.confirm")) {
|
|
1292
|
+
const trimmed = noteDraft.trim();
|
|
1293
|
+
if (trimmed) notes.set(noteKey, trimmed);
|
|
1294
|
+
else notes.delete(noteKey);
|
|
1295
|
+
noteKey = null;
|
|
1296
|
+
noteDraft = "";
|
|
1297
|
+
tui.requestRender();
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
1301
|
+
noteKey = null;
|
|
1302
|
+
noteDraft = "";
|
|
1303
|
+
tui.requestRender();
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
|
|
1307
|
+
noteDraft = noteDraft.slice(0, -1);
|
|
1308
|
+
tui.requestRender();
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (isPrintable(data)) {
|
|
1312
|
+
if (noteDraft.length < 200) noteDraft += data;
|
|
1313
|
+
tui.requestRender();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
return; // swallow other keys while editing
|
|
1317
|
+
}
|
|
1318
|
+
const row = rows[cursor];
|
|
1319
|
+
// Withdraw (Esc / Ctrl+C).
|
|
1320
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
1321
|
+
finish({ kind: "withdraw" });
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
// Space or Enter: toggle at the cursor (tier or item), or
|
|
1325
|
+
// confirm on the Confirm row.
|
|
1326
|
+
if (matchesKey(data, "space") || kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
|
|
1327
|
+
if (row.kind === "confirm") {
|
|
1328
|
+
confirmIfPossible();
|
|
1329
|
+
} else {
|
|
1330
|
+
toggleAt(row);
|
|
1331
|
+
tui.requestRender();
|
|
1332
|
+
}
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
// a / Ctrl+A: select all.
|
|
1336
|
+
if (data === "a" || matchesKey(data, "ctrl+a")) {
|
|
1337
|
+
selection = planSelectAll(proposal, selection);
|
|
1338
|
+
tui.requestRender();
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
// e: edit the note for the item under the cursor.
|
|
1342
|
+
if (data === "e") {
|
|
1343
|
+
if (row.kind === "item") {
|
|
1344
|
+
const key = planItemKey(row.tier, row.index);
|
|
1345
|
+
noteKey = key;
|
|
1346
|
+
noteDraft = notes.get(key) ?? "";
|
|
1347
|
+
tui.requestRender();
|
|
1348
|
+
} else {
|
|
1349
|
+
ctx.ui.notify("do-always: notes attach to item rows — put the cursor on an item, then press e", "info");
|
|
1350
|
+
}
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
// Ctrl+U: clear the selection.
|
|
1354
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
1355
|
+
selection = planSelectionClear();
|
|
1356
|
+
tui.requestRender();
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
// Navigation (wraps at the edges).
|
|
1360
|
+
if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
|
|
1361
|
+
cursor = cursor === 0 ? rows.length - 1 : cursor - 1;
|
|
1362
|
+
tui.requestRender();
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
|
|
1366
|
+
cursor = cursor === rows.length - 1 ? 0 : cursor + 1;
|
|
1367
|
+
tui.requestRender();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (matchesKey(data, "home")) {
|
|
1371
|
+
cursor = 0;
|
|
1372
|
+
tui.requestRender();
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (matchesKey(data, "end")) {
|
|
1376
|
+
cursor = rows.length - 1;
|
|
1377
|
+
tui.requestRender();
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
1383
|
+
// Wheel: move the cursor one row (the scroll window follows) —
|
|
1384
|
+
// the same behavior as the task selector.
|
|
1385
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
1386
|
+
if (noteKey !== null) return { handled: true };
|
|
1387
|
+
const delta = event.wheelDelta < 0 ? -1 : 1;
|
|
1388
|
+
const next = Math.max(0, Math.min(rows.length - 1, cursor + delta));
|
|
1389
|
+
if (next === cursor) return { handled: true };
|
|
1390
|
+
cursor = next;
|
|
1391
|
+
return { handled: true, render: true };
|
|
1392
|
+
}
|
|
1393
|
+
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
|
|
1394
|
+
if (noteKey !== null) return { handled: true }; // clicks are swallowed while editing
|
|
1395
|
+
const row = rowLine.get(event.y);
|
|
1396
|
+
if (!row) return undefined;
|
|
1397
|
+
if (row.kind === "confirm") {
|
|
1398
|
+
if (event.type === "press") confirmIfPossible();
|
|
1399
|
+
return { handled: true };
|
|
1400
|
+
}
|
|
1401
|
+
if (event.type === "press") {
|
|
1402
|
+
toggleAt(row);
|
|
1403
|
+
return { handled: true, render: true };
|
|
1404
|
+
}
|
|
1405
|
+
return { handled: true };
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
return { render, handleInput, handleMouse, invalidate: () => {} };
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
|
|
965
1413
|
// The in-flight chain's report file: its path (absolute + relative for
|
|
966
1414
|
// display), the precomputed header (deferred — written together with the
|
|
967
1415
|
// first step section, so a chain that dies before that leaves no
|
|
@@ -1232,6 +1680,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1232
1680
|
clearTimeout(pendingSummaryTimer);
|
|
1233
1681
|
pendingSummaryTimer = null;
|
|
1234
1682
|
}
|
|
1683
|
+
// A new run began before the captured plan proposal was offered (the
|
|
1684
|
+
// user typed a prompt right after the Plan run ended) — it is stale.
|
|
1685
|
+
pendingProposal = null;
|
|
1686
|
+
pendingPlanRaw = null;
|
|
1235
1687
|
// The run actually began — time the step for the report.
|
|
1236
1688
|
if (chainReport) chainReport.stepStartedAt = new Date();
|
|
1237
1689
|
// Fill-first: step 1 left the editor and is running — update the
|
|
@@ -1261,6 +1713,69 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1261
1713
|
return "completed";
|
|
1262
1714
|
}
|
|
1263
1715
|
|
|
1716
|
+
/**
|
|
1717
|
+
* The message with every fenced plan block removed from its text parts,
|
|
1718
|
+
* or null when nothing changed. Strips per part: a fence spanning two
|
|
1719
|
+
* parts is left in place (the questionnaire parser works on the joined
|
|
1720
|
+
* text, so its behavior is unaffected by that edge case).
|
|
1721
|
+
*/
|
|
1722
|
+
function stripPlanFromMessage(message: MessageEndEvent["message"]): MessageEndEvent["message"] | null {
|
|
1723
|
+
if (message.role !== "assistant") return null;
|
|
1724
|
+
// Assistant content is a parts array (text / thinking / toolCall);
|
|
1725
|
+
// only text parts can carry the plan fence.
|
|
1726
|
+
const content = message.content;
|
|
1727
|
+
if (!Array.isArray(content)) return null;
|
|
1728
|
+
let removed = false;
|
|
1729
|
+
const parts = content.map((part) => {
|
|
1730
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
1731
|
+
const { text, removed: partRemoved } = stripPlanBlocks(part.text);
|
|
1732
|
+
if (partRemoved) {
|
|
1733
|
+
removed = true;
|
|
1734
|
+
return { ...part, text };
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
return part;
|
|
1738
|
+
});
|
|
1739
|
+
return removed ? { ...message, content: parts } : null;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Hide the plan block: while a single auto-run Plan task is in flight
|
|
1744
|
+
* (its prompt carried PLAN_OUTPUT_INSTRUCTION), capture the reply's raw
|
|
1745
|
+
* text for the questionnaire, then — in the TUI — replace the finalized
|
|
1746
|
+
* message with the plan block(s) stripped out. The runtime applies the
|
|
1747
|
+
* replacement in place, so the stripped text is what the model sees in
|
|
1748
|
+
* later turns and what the session file persists. In non-TUI modes the
|
|
1749
|
+
* block stays in the transcript: it is the model's only record of the
|
|
1750
|
+
* proposal, and the user may reply with item numbers to execute. Scoped
|
|
1751
|
+
* to the pending auto-run Plan task (renderTaskPrompt's gate), so a
|
|
1752
|
+
* plan-tagged JSON block in a normal conversation or in a non-Plan
|
|
1753
|
+
* auto-run's reply is never touched; `hidePlan` (per task, then global)
|
|
1754
|
+
* opts out of the strip.
|
|
1755
|
+
*/
|
|
1756
|
+
pi.on("message_end", (event) => {
|
|
1757
|
+
if (event.message.role !== "assistant" || !pendingSummaryTask) return;
|
|
1758
|
+
const task = tasks.find((t) => t.name === pendingSummaryTask);
|
|
1759
|
+
// Only the runs whose prompt carried PLAN_OUTPUT_INSTRUCTION (Plan
|
|
1760
|
+
// tasks with the questionnaire enabled — renderTaskPrompt's gate)
|
|
1761
|
+
// may have the block captured and stripped; a plan fence in any other
|
|
1762
|
+
// reply is the user's content and stays in the transcript.
|
|
1763
|
+
if (!task || !isPlanTask(task) || !(task.questionnaire ?? questionnaireEnabled)) return;
|
|
1764
|
+
const text = assistantText(event.message.content);
|
|
1765
|
+
if (!text.includes("```plan")) return;
|
|
1766
|
+
// Capture the raw (unstripped) text for the questionnaire — after the
|
|
1767
|
+
// strip below the message text no longer carries the block.
|
|
1768
|
+
pendingPlanRaw = text;
|
|
1769
|
+
// TUI-only strip: in non-TUI modes the block stays in the transcript
|
|
1770
|
+
// so the model can resolve the item-number replies the notification
|
|
1771
|
+
// offers (and the session file keeps the proposal on record).
|
|
1772
|
+
if (lastCtx?.mode !== "tui") return;
|
|
1773
|
+
// `hidePlan` (per task, then global) opts out of the strip.
|
|
1774
|
+
if (!(task.hidePlan ?? hidePlanEnabled)) return;
|
|
1775
|
+
const stripped = stripPlanFromMessage(event.message);
|
|
1776
|
+
if (stripped) return { message: stripped };
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1264
1779
|
pi.on("agent_end", (event) => {
|
|
1265
1780
|
// The agent may have changed the repo — drop the TTL context cache so
|
|
1266
1781
|
// the next action sees the new tree (a running chain keeps its own
|
|
@@ -1276,10 +1791,26 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1276
1791
|
const lastAssistant = lastAssistantMessage(event.messages);
|
|
1277
1792
|
if (lastAssistant) {
|
|
1278
1793
|
const outcome = outcomeFromStopReason(lastAssistant.stopReason);
|
|
1279
|
-
|
|
1280
|
-
|
|
1794
|
+
if (outcome === "completed") {
|
|
1795
|
+
// Completed auto-run: capture the reply; the plan
|
|
1796
|
+
// questionnaire (or the plain summary when there is no
|
|
1797
|
+
// parseable plan block / the mode is not TUI) is offered at
|
|
1798
|
+
// agent_settled, when the session is fully idle. The
|
|
1799
|
+
// message_end handler stripped the plan block from the
|
|
1800
|
+
// transcript, so the message here no longer carries it — the
|
|
1801
|
+
// raw capture is the parse source (the message text is the
|
|
1802
|
+
// fallback when no capture exists).
|
|
1803
|
+
pendingProposal = {
|
|
1804
|
+
taskName: pendingSummaryTask,
|
|
1805
|
+
text: pendingPlanRaw ?? assistantText(lastAssistant.content),
|
|
1806
|
+
};
|
|
1807
|
+
} else {
|
|
1808
|
+
const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
|
|
1809
|
+
lastCtx?.ui.notify(`do-always: ${summary}`, "info");
|
|
1810
|
+
}
|
|
1281
1811
|
}
|
|
1282
1812
|
pendingSummaryTask = null;
|
|
1813
|
+
pendingPlanRaw = null;
|
|
1283
1814
|
return;
|
|
1284
1815
|
}
|
|
1285
1816
|
if (!chainWaiter) return;
|
|
@@ -1325,9 +1856,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1325
1856
|
}
|
|
1326
1857
|
}
|
|
1327
1858
|
});
|
|
1859
|
+
// The settle that follows a chain run's agent_end (or the
|
|
1860
|
+
// failed-to-start grace timer): settle the waiter there, not in
|
|
1861
|
+
// agent_end, because agent_end can fire while a queued follow-up is
|
|
1862
|
+
// still pending — the settle is the point where the session is
|
|
1863
|
+
// truly idle. A settle with no waiter is a plain user turn (or the
|
|
1864
|
+
// settle of a completed auto-run, which offers its captured reply).
|
|
1328
1865
|
pi.on("agent_settled", () => {
|
|
1329
|
-
if (
|
|
1330
|
-
|
|
1866
|
+
if (chainWaiter) {
|
|
1867
|
+
settleChainWaiter(chainWaiter.started ? (chainWaiter.outcome ?? "completed") : "failed-to-start");
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
// A completed auto-run task may have a captured reply to offer (the
|
|
1871
|
+
// plan questionnaire in TUI, the summary elsewhere). Skip when a chain
|
|
1872
|
+
// is active — the questionnaire is only for single auto-runs.
|
|
1873
|
+
if (pendingProposal && !chainActive) {
|
|
1874
|
+
const captured = pendingProposal;
|
|
1875
|
+
pendingProposal = null;
|
|
1876
|
+
void offerPlanProposal(captured, lastCtx);
|
|
1877
|
+
}
|
|
1331
1878
|
});
|
|
1332
1879
|
|
|
1333
1880
|
/**
|
|
@@ -1353,11 +1900,11 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1353
1900
|
* Send a prompt and resolve when the run it starts has fully settled,
|
|
1354
1901
|
* reporting the run's outcome (see armWaiter).
|
|
1355
1902
|
*/
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1903
|
+
function sendAndWait(prompt: string, graceMs = 10_000, stepIndex = 0): Promise<ChainStepOutcome> {
|
|
1904
|
+
const done = armWaiter(graceMs, stepIndex);
|
|
1905
|
+
pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
|
|
1906
|
+
return done;
|
|
1907
|
+
}
|
|
1361
1908
|
|
|
1362
1909
|
/** Filter tasks by visibility (`hidden` flag + `when` condition) and refresh the completion cache. */
|
|
1363
1910
|
function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
|
|
@@ -1403,6 +1950,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1403
1950
|
const config = loadConfig(ctx.cwd, onError);
|
|
1404
1951
|
tasks = config.tasks;
|
|
1405
1952
|
reportEnabled = config.report;
|
|
1953
|
+
questionnaireEnabled = config.questionnaire;
|
|
1954
|
+
hidePlanEnabled = config.hidePlan;
|
|
1406
1955
|
refreshVisible(ctx.cwd, await getContext(ctx.cwd));
|
|
1407
1956
|
registerShortcut(config.shortcut, onError);
|
|
1408
1957
|
});
|
|
@@ -1528,8 +2077,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1528
2077
|
const details = formatSelectedCommits(selected);
|
|
1529
2078
|
const strings = toPromptContext(context);
|
|
1530
2079
|
const prompt = /\{\{\s*selected_commits\s*\}\}/.test(chosen.prompt)
|
|
1531
|
-
?
|
|
1532
|
-
: `${
|
|
2080
|
+
? renderTaskPrompt(chosen, { ...strings, selected_commits: details })
|
|
2081
|
+
: `${renderTaskPrompt(chosen, strings)}\n\nSelected commits:\n${details}`;
|
|
1533
2082
|
|
|
1534
2083
|
pendingSummaryTask = chosen.name;
|
|
1535
2084
|
if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
|
|
@@ -1540,7 +2089,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1540
2089
|
pendingSummaryTask = null;
|
|
1541
2090
|
lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
|
|
1542
2091
|
}, 10_000);
|
|
1543
|
-
pi.sendUserMessage(prompt);
|
|
2092
|
+
pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
|
|
1544
2093
|
ctx.ui.notify(
|
|
1545
2094
|
`do-always: ${selected.length} commit${selected.length !== 1 ? "s" : ""} → ${chosen.name}`,
|
|
1546
2095
|
"info",
|
|
@@ -1554,7 +2103,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1554
2103
|
}
|
|
1555
2104
|
// Render with the same context the selector/preview used, so what the
|
|
1556
2105
|
// user saw is exactly what gets injected.
|
|
1557
|
-
const prompt =
|
|
2106
|
+
const prompt = renderTaskPrompt(task, toPromptContext(context));
|
|
1558
2107
|
if (shouldAutoRun(task)) {
|
|
1559
2108
|
// Fire-and-forget: sendUserMessage returns void; the run proceeds
|
|
1560
2109
|
// independently (see the chain control notes for why).
|
|
@@ -1571,7 +2120,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1571
2120
|
pendingSummaryTask = null;
|
|
1572
2121
|
lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
|
|
1573
2122
|
}, 10_000);
|
|
1574
|
-
pi.sendUserMessage(prompt);
|
|
2123
|
+
pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
|
|
1575
2124
|
ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
|
|
1576
2125
|
return;
|
|
1577
2126
|
}
|
|
@@ -1579,7 +2128,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1579
2128
|
ctx.ui.setEditorText(prompt);
|
|
1580
2129
|
ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
|
|
1581
2130
|
} else {
|
|
1582
|
-
pi.sendUserMessage(prompt);
|
|
2131
|
+
pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
|
|
1583
2132
|
}
|
|
1584
2133
|
}
|
|
1585
2134
|
|
|
@@ -2081,7 +2630,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2081
2630
|
const sel = itemRows[cursor.row];
|
|
2082
2631
|
if (sel) {
|
|
2083
2632
|
const wrapWidth = Math.max(10, width - 4);
|
|
2084
|
-
const wrapped = wrapTextWithAnsi(
|
|
2633
|
+
const wrapped = wrapTextWithAnsi(renderTaskPrompt(sel.task, strings), wrapWidth);
|
|
2085
2634
|
const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
|
|
2086
2635
|
const truncated = wrapped.length > PREVIEW_MAX_LINES;
|
|
2087
2636
|
lines.push("");
|
|
@@ -2429,6 +2978,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2429
2978
|
const matches = [
|
|
2430
2979
|
{ value: "list", label: "list" },
|
|
2431
2980
|
{ value: "list-details", label: "list-details" },
|
|
2981
|
+
{ value: "replan", label: "replan" },
|
|
2432
2982
|
...visible.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
|
|
2433
2983
|
].filter((c) => c.value.toLowerCase().includes(p));
|
|
2434
2984
|
return matches.length > 0 ? matches : null;
|
|
@@ -2451,6 +3001,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2451
3001
|
const config = loadConfig(ctx.cwd, onError);
|
|
2452
3002
|
tasks = config.tasks;
|
|
2453
3003
|
reportEnabled = config.report;
|
|
3004
|
+
questionnaireEnabled = config.questionnaire;
|
|
3005
|
+
hidePlanEnabled = config.hidePlan;
|
|
2454
3006
|
}
|
|
2455
3007
|
|
|
2456
3008
|
// One context per command run: shared by visibility filtering, rendering,
|
|
@@ -2475,6 +3027,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2475
3027
|
return;
|
|
2476
3028
|
}
|
|
2477
3029
|
|
|
3030
|
+
// Re-open the questionnaire for the last offered plan proposal (e.g.
|
|
3031
|
+
// after an accidental esc). Re-offers the same captured reply, so the
|
|
3032
|
+
// confirm/withdraw behavior is exactly as before.
|
|
3033
|
+
if (arg.toLowerCase() === "replan") {
|
|
3034
|
+
if (!lastProposal) {
|
|
3035
|
+
ctx.ui.notify("do-always: no plan proposal to re-open — run a Plan task (⚡) first", "info");
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
void offerPlanProposal(lastProposal, ctx);
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
2478
3042
|
if (arg.toLowerCase() === "list-details") {
|
|
2479
3043
|
// Display only — the description is metadata; selecting a task injects just its prompt.
|
|
2480
3044
|
// Render with the current context so what is shown is what gets injected.
|
|
@@ -2484,7 +3048,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2484
3048
|
const lines = [`${i + 1}. ${t.name}`];
|
|
2485
3049
|
if (t.description) lines.push(` description: ${t.description}`);
|
|
2486
3050
|
lines.push(" prompt (this is what gets injected on select):");
|
|
2487
|
-
for (const line of
|
|
3051
|
+
for (const line of renderTaskPrompt(t, strings).split("\n")) lines.push(` ${line}`);
|
|
2488
3052
|
return lines.join("\n");
|
|
2489
3053
|
})
|
|
2490
3054
|
.join("\n\n");
|