Skip to content

Commit f3bb3eb

Browse files
authored
Merge branch 'main' into changeset-release/main
2 parents 4f1def4 + 2ce6b5e commit f3bb3eb

23 files changed

Lines changed: 1282 additions & 66 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@pythoughts/pythinker-code-sdk': minor
3+
'@pythoughts/pythinker-code': minor
4+
---
5+
6+
Show the plan before a Dynamic Workflow runs, and let a good one be saved as a command
7+
8+
Manual mode used to approve every `DynamicWorkflow` call outright. That approval
9+
only ever fired in manual mode — auto and yolo approve earlier in the chain — so
10+
the one mode whose purpose is to ask was the one mode that never saw what it was
11+
agreeing to. A `DynamicWorkflow` call in manual mode now asks, and the approval
12+
carries the fan-out: how many subagents, the task list, the prompt template, the
13+
worker model, and the summed size of the prompts about to be sent. "Approve for
14+
this session" is keyed to that workflow's description rather than granting every
15+
future `DynamicWorkflow` call.
16+
17+
`/workflow save <name>` writes the last run back out as a skill under
18+
`.pythinker-code/skills/`, so a fan-out that worked can be re-run by name.

apps/pythinker-code/src/tui/commands/dynamic-workflow.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import type { PermissionMode } from '@pythoughts/pythinker-code-sdk';
1+
import {
2+
savedWorkflowSkillName,
3+
writeSavedWorkflowSkill,
4+
type PermissionMode,
5+
} from '@pythoughts/pythinker-code-sdk';
26

7+
import { getDataDir } from '#/utils/paths';
38
import {
49
DynamicWorkflowStartPermissionPromptComponent,
510
type DynamicWorkflowStartPermissionChoice,
@@ -25,6 +30,7 @@ export async function handleDynamicWorkflowCommand(host: SlashCommandHost, args:
2530

2631
const prompt = args.trim();
2732
if (handleModelSubcommand(host, prompt)) return;
33+
if (await handleSaveSubcommand(host, prompt)) return;
2834

2935
const mode = dynamicWorkflowModeSubcommand(prompt);
3036
if (mode !== undefined) {
@@ -114,6 +120,69 @@ function withWorkerModelInstruction(prompt: string, model: string | undefined):
114120
: `${prompt}\n\nUse model "${model}" for the DynamicWorkflow subagents in this task.`;
115121
}
116122

123+
/**
124+
* `/workflow save <name>` writes the last run back out as a skill, so a fan-out
125+
* that worked can be re-run by name instead of re-described.
126+
*
127+
* Returns true when the input was a `save` subcommand and has been handled.
128+
*/
129+
async function handleSaveSubcommand(host: SlashCommandHost, input: string): Promise<boolean> {
130+
const match = /^save(?:\s+(.*))?$/iu.exec(input);
131+
if (match === null) return false;
132+
133+
const name = match[1]?.trim() ?? '';
134+
if (name.length === 0) {
135+
host.showError('Usage: /workflow save <name>');
136+
return true;
137+
}
138+
139+
const args = host.state.lastDynamicWorkflowArgs;
140+
if (args === undefined) {
141+
host.showError('No Dynamic Workflow has run in this session yet.');
142+
return true;
143+
}
144+
145+
const description = stringArg(args, 'description');
146+
if (description === undefined) {
147+
host.showError('The last Dynamic Workflow has no description to save.');
148+
return true;
149+
}
150+
151+
try {
152+
const dir = await writeSavedWorkflowSkill({
153+
scope: 'project',
154+
projectRoot: host.state.appState.workDir,
155+
brandHomeDir: getDataDir(),
156+
workflow: {
157+
name,
158+
description,
159+
subagentType: stringArg(args, 'subagent_type'),
160+
promptTemplate: stringArg(args, 'prompt_template'),
161+
model: stringArg(args, 'model'),
162+
effort: stringArg(args, 'effort'),
163+
outputSchema: recordArg(args, 'output_schema'),
164+
},
165+
});
166+
host.refreshSlashCommandAutocomplete();
167+
host.showStatus(`Saved /${savedWorkflowSkillName(name)} to ${dir}.`);
168+
} catch (error) {
169+
host.showError(`Failed to save workflow: ${formatErrorMessage(error)}`);
170+
}
171+
return true;
172+
}
173+
174+
function stringArg(args: Record<string, unknown>, key: string): string | undefined {
175+
const value = args[key];
176+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
177+
}
178+
179+
function recordArg(args: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
180+
const value = args[key];
181+
return typeof value === 'object' && value !== null && !Array.isArray(value)
182+
? (value as Record<string, unknown>)
183+
: undefined;
184+
}
185+
117186
/** Returns true when the input was a `model` subcommand and has been handled. */
118187
function handleModelSubcommand(host: SlashCommandHost, input: string): boolean {
119188
const match = /^model(?:\s+(.*))?$/iu.exec(input);

apps/pythinker-code/src/tui/commands/registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const DYNAMIC_WORKFLOW_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
2121
{ value: 'on', description: 'Turn Dynamic Workflow mode on' },
2222
{ value: 'off', description: 'Turn Dynamic Workflow mode off' },
2323
{ value: 'model', description: 'Set the model Dynamic Workflow subagents run on' },
24+
{ value: 'save', description: 'Save the last Dynamic Workflow as a reusable command' },
2425
];
2526

2627
const FAST_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [

apps/pythinker-code/src/tui/components/dialogs/approval-panel.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* Container-based component with keyboard navigation.
55
*/
66

7+
import { stripVTControlCharacters } from 'node:util';
8+
79
import {
810
Container,
911
Input,
@@ -32,6 +34,7 @@ import type {
3234
DisplayBlock,
3335
FileContentDisplayBlock,
3436
PendingApproval,
37+
WorkflowPlanDisplayBlock,
3538
} from '#/tui/reverse-rpc/types';
3639
import { printableChar } from '#/tui/utils/printable-key';
3740

@@ -167,6 +170,8 @@ function renderDisplayBlock(
167170
}
168171
return lines;
169172
}
173+
case 'workflow_plan':
174+
return renderWorkflowPlanDisplayBlock(block, s);
170175
case 'brief':
171176
return block.text
172177
? block.text.split('\n').map((line) => (line.length > 0 ? s.strong(line) : ''))
@@ -182,6 +187,57 @@ function renderDisplayBlock(
182187
}
183188
}
184189

190+
/**
191+
* A workflow can carry up to 128 items. Listing all of them would push the
192+
* buttons off the screen, so the panel shows enough to judge the shape of the
193+
* fan-out and says how many it held back.
194+
*/
195+
const MAX_PREVIEW_ITEMS = 10;
196+
197+
/**
198+
* The plan is the thing being approved, and every field in it came from the
199+
* model. Escape sequences would let that text repaint the panel it is being
200+
* judged in — hide a line, redraw the buttons, or reverse the reading order —
201+
* so they are removed rather than styled. `stripVTControlCharacters` takes the
202+
* CSI and OSC sequences; the class escape then takes the bare control and
203+
* format characters it leaves behind, which include the bidi overrides.
204+
*/
205+
function sanitizePlanText(text: string): string {
206+
return stripVTControlCharacters(text).replaceAll(/[\p{Cc}\p{Cf}]/gu, ' ');
207+
}
208+
209+
function renderWorkflowPlanDisplayBlock(
210+
block: WorkflowPlanDisplayBlock,
211+
s: BlockStyles,
212+
): string[] {
213+
const plural = block.agent_count === 1 ? 'subagent' : 'subagents';
214+
const summary = [
215+
`${String(block.agent_count)} ${plural}`,
216+
`~${String(block.prompt_tokens)} prompt tokens`,
217+
];
218+
if (block.model !== undefined && block.model.length > 0) {
219+
summary.push(`model: ${sanitizePlanText(block.model)}`);
220+
}
221+
const lines = [s.strong(summary.join(' '))];
222+
223+
if (block.prompt_template !== undefined && block.prompt_template.length > 0) {
224+
lines.push(
225+
`${s.accent('prompt')} ${s.dim(truncateOneLine(sanitizePlanText(block.prompt_template), 200))}`,
226+
);
227+
}
228+
229+
for (const [index, item] of block.items.slice(0, MAX_PREVIEW_ITEMS).entries()) {
230+
lines.push(
231+
s.dim(`${String(index + 1).padStart(3)}. ${truncateOneLine(sanitizePlanText(item), 120)}`),
232+
);
233+
}
234+
const hidden = block.items.length - MAX_PREVIEW_ITEMS;
235+
if (hidden > 0) {
236+
lines.push(s.dim(` +${String(hidden)} more`));
237+
}
238+
return lines;
239+
}
240+
185241
function normalizeApprovalText(text: string): string {
186242
return text.replaceAll('\r\n', '\n').trim();
187243
}
@@ -209,6 +265,8 @@ function headerFor(toolName: string): string {
209265
return 'Stop this task?';
210266
case 'ExitPlanMode':
211267
return 'Ready to build with this plan?';
268+
case 'DynamicWorkflow':
269+
return 'Run this Dynamic Workflow?';
212270
default:
213271
return `Approve ${toolName}?`;
214272
}

apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ export class SubAgentEventHandler {
275275
if (this.isRetiredDynamicWorkflowToolCall(toolCallId)) return;
276276
const missionControl = this.ensureDynamicWorkflowMissionControl(toolCallId, args);
277277
missionControl.markInputComplete();
278+
// Captured here rather than in `ensure…`, which the delta path also calls:
279+
// mid-stream arguments are half-parsed, and saving those would write a
280+
// workflow missing most of its items.
281+
this.host.state.lastDynamicWorkflowArgs = args;
278282
this.requestRender();
279283
}
280284

apps/pythinker-code/src/tui/reverse-rpc/approval/adapter.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,15 +300,27 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] {
300300
scope: display.scope,
301301
},
302302
];
303-
case 'agent_call':
304-
return [
303+
case 'agent_call': {
304+
const blocks: DisplayBlock[] = [
305305
{
306306
type: 'invocation',
307307
kind: 'agent',
308308
name: display.agent_name ?? '',
309309
description: display.prompt,
310310
},
311311
];
312+
if (display.workflow !== undefined) {
313+
blocks.push({
314+
type: 'workflow_plan',
315+
agent_count: display.workflow.agent_count,
316+
items: [...display.workflow.items],
317+
prompt_tokens: display.workflow.prompt_tokens,
318+
prompt_template: display.workflow.prompt_template,
319+
model: display.workflow.model,
320+
});
321+
}
322+
return blocks;
323+
}
312324
case 'skill_call':
313325
return [
314326
{

apps/pythinker-code/src/tui/reverse-rpc/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ export interface InvocationDisplayBlock {
6868
description?: string | undefined;
6969
}
7070

71+
/**
72+
* The fan-out a Dynamic Workflow is about to launch. Shown at approval time so
73+
* the decision is made against the actual task list rather than a count.
74+
*/
75+
export interface WorkflowPlanDisplayBlock {
76+
type: 'workflow_plan';
77+
agent_count: number;
78+
items: string[];
79+
prompt_tokens: number;
80+
prompt_template?: string;
81+
model?: string;
82+
}
83+
7184
export interface TodoDisplayItem {
7285
title: string;
7386
status: 'pending' | 'in_progress' | 'done';
@@ -95,6 +108,7 @@ export type DisplayBlock =
95108
| UrlFetchDisplayBlock
96109
| SearchDisplayBlock
97110
| InvocationDisplayBlock
111+
| WorkflowPlanDisplayBlock
98112
| TodoDisplayBlock
99113
| BackgroundTaskDisplayBlock;
100114

apps/pythinker-code/src/tui/tui-state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ export interface TUIState {
6363
externalEditorRunning: boolean;
6464
queuedMessages: QueuedMessage[];
6565
dynamicWorkflowModeEntry: 'manual' | 'task' | undefined;
66+
/**
67+
* Arguments of the most recent DynamicWorkflow tool call, so `/workflow save`
68+
* can turn a run that just worked into a reusable command. Overwritten as the
69+
* call streams in; the last write is the complete one.
70+
*/
71+
lastDynamicWorkflowArgs: Record<string, unknown> | undefined;
6672
}
6773

6874
export function createTUIState(options: PythinkerTUIOptions): TUIState {
@@ -143,5 +149,6 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState {
143149
externalEditorRunning: false,
144150
queuedMessages: [],
145151
dynamicWorkflowModeEntry: undefined,
152+
lastDynamicWorkflowArgs: undefined,
146153
};
147154
}

0 commit comments

Comments
 (0)