Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sdk-advisor-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code-sdk": minor
---

Add advisor status and control methods to the session client.
5 changes: 5 additions & 0 deletions .changeset/session-advisor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Add a session advisor that reviews work in the background, with the /advisor command to show and control it.
5 changes: 5 additions & 0 deletions .changeset/thinking-activity-pane.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Stream in-progress thinking in the activity pane and move it into the transcript when complete.
5 changes: 5 additions & 0 deletions .changeset/workflow-spinner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Show running dynamic workflow rows with the same braille spinner glyphs as other loaders.
90 changes: 90 additions & 0 deletions apps/pythinker-code/src/tui/commands/advisor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { formatErrorMessage, type AdvisorStatusSnapshot } from '@pymodel/pythinker-code-sdk';
import type { SlashCommandHost } from './dispatch';

const ADVISOR_STATUS_GLYPHS: Record<string, string> = {
running: '●',
paused: '○',
no_model: '○',
quota_exhausted: '✕',
error: '✕',
};

const ADVISOR_STATUS_LABELS: Record<string, string> = {
running: 'running',
paused: 'off',
no_model: 'no model',
quota_exhausted: 'quota exhausted',
error: 'error',
};

export async function handleAdvisorCommand(host: SlashCommandHost, args: string): Promise<void> {
const parts = args.trim().split(/\s+/u).filter(Boolean);
const verb = parts[0] ?? 'status';
const advisorId = parts[1];
if (parts.length > 2 || !['on', 'off', 'reload', 'status', 'toggle'].includes(verb)) {
host.showError('Usage: /advisor [on|off|status|reload|toggle] [advisor-id]');
return;
}
if (host.session === undefined) {
host.showError('No active session.');
return;
}

if (verb === 'status') {
host.showNotice('Advisor status', formatAdvisorStatuses(await host.session.advisor.status()));
return;
}
if (verb === 'reload') {
await host.session.advisor.reload();
host.showStatus('Advisor configuration reloaded.');
return;
}

const statuses = await host.session.advisor.status();
if (advisorId !== undefined && !statuses.some((status) => status.id === advisorId)) {
host.showError(
`Unknown advisor: ${advisorId}. Run /advisor status to list configured advisors.`,
);
return;
}
const enabled =
verb === 'toggle'
? !(
statuses.find((status) =>
advisorId === undefined ? true : status.id === advisorId,
)?.enabled ?? false
)
: verb === 'on';
let updatedStatuses: readonly AdvisorStatusSnapshot[];
try {
updatedStatuses = await host.session.advisor.setEnabled(enabled, advisorId);
} catch (error) {
host.showError(formatErrorMessage(error));
return;
}
const target = advisorId === undefined ? 'Advisor' : `Advisor ${advisorId}`;
const applied =
advisorId === undefined
? updatedStatuses.length > 0 &&
updatedStatuses.every((status) => status.enabled === enabled)
: updatedStatuses.find((status) => status.id === advisorId)?.enabled === enabled;
if (!applied) {
host.showError(`${target} remains ${enabled ? 'disabled' : 'enabled'}.`);
return;
}
host.showStatus(`${target} ${enabled ? 'enabled' : 'disabled'}.`);
}

function formatAdvisorStatuses(statuses: readonly AdvisorStatusSnapshot[]): string {
if (statuses.length === 0) return 'Advisor is disabled.';
return statuses
.map((advisor) => {
const glyph = ADVISOR_STATUS_GLYPHS[advisor.status] ?? '?';
const label = ADVISOR_STATUS_LABELS[advisor.status] ?? advisor.status;
const model = advisor.model === undefined ? '' : `\n Model: ${advisor.model}`;
const details = `\n ${advisor.notes} notes · $${advisor.costUsd.toFixed(4)} · ${advisor.failures} failures`;
const message = advisor.message === undefined ? '' : `\n ${advisor.message}`;
return `${glyph} ${advisor.name} [${label}]${advisor.enabled ? '' : ' (disabled)'}${model}${details}${message}`;
})
.join('\n\n');
}
5 changes: 4 additions & 1 deletion apps/pythinker-code/src/tui/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,10 @@ async function showWorkingTreeFileDiff(
'primary',
' Diff ',
);
host.state.transcriptContainer.addChild(panel);
host.state.transcriptContainer.addTranscriptChild(panel, {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
} catch (error) {
host.showError(`Failed to load diff for ${path}: ${formatErrorMessage(error)}`);
Expand Down
4 changes: 4 additions & 0 deletions apps/pythinker-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
TranscriptEntry,
} from '../types';
import { formatErrorMessage } from '../utils/event-payload';
import { handleAdvisorCommand } from './advisor';
import { handleAddDirCommand } from './add-dir';
import { handleAgentsCommand } from './agents';
import { handleLoginCommand, handleLogoutCommand } from './auth';
Expand Down Expand Up @@ -388,6 +389,9 @@ async function handleBuiltInSlashCommand(
case 'fast':
await handleFastCommand(host, args);
return;
case 'advisor':
await handleAdvisorCommand(host, args);
return;
case 'provider':
await handleProviderCommand(host);
return;
Expand Down
3 changes: 2 additions & 1 deletion apps/pythinker-code/src/tui/commands/dynamic-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,9 @@ function dynamicWorkflowModeSubcommand(input: string): boolean | undefined {
}

function renderDynamicWorkflowModeMarker(host: SlashCommandHost, state: DynamicWorkflowModeMarkerState): void {
host.state.transcriptContainer.addChild(
host.state.transcriptContainer.addTranscriptChild(
new DynamicWorkflowModeMarkerComponent(state),
{ role: 'ephemeral', edgeBlankPolicy: 'preserve' },
);
host.state.ui.requestRender();
}
15 changes: 10 additions & 5 deletions apps/pythinker-code/src/tui/commands/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,9 @@ async function queueNextGoal(
}
host.track('goal_queue_append');
if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.();
host.state.transcriptContainer.addChild(
host.state.transcriptContainer.addTranscriptChild(
new UpcomingGoalAddedMessageComponent(),
{ role: 'ephemeral', edgeBlankPolicy: 'preserve' },
);
host.state.ui.requestRender();
}
Expand Down Expand Up @@ -413,7 +414,10 @@ async function startGoal(
return false;
}
host.track('goal_create', { replace: parsed.replace });
host.state.transcriptContainer.addChild(new GoalSetMessageComponent());
host.state.transcriptContainer.addTranscriptChild(new GoalSetMessageComponent(), {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
if (options.sendInput !== undefined) {
options.sendInput(parsed.objective);
Expand Down Expand Up @@ -484,9 +488,10 @@ async function showGoalStatus(host: SlashCommandHost): Promise<void> {
host.showStatus('No goal set. Start one with `/goal <objective>`.');
return;
}
host.state.transcriptContainer.addChild(
new GoalStatusMessageComponent(goal),
);
host.state.transcriptContainer.addTranscriptChild(new GoalStatusMessageComponent(goal), {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
}

Expand Down
1 change: 1 addition & 0 deletions apps/pythinker-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {
export { handleCopyCommand, showMessageActions } from './copy';
export { handleDebugCommand } from './debug';
export { buildWorkingTreeDiffLines, handleDiffCommand } from './diff';
export { handleAdvisorCommand } from './advisor';
export { handleDynamicWorkflowCommand } from './dynamic-workflow';
export { handleFastCommand } from './fast';
export {
Expand Down
25 changes: 20 additions & 5 deletions apps/pythinker-code/src/tui/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ export function showCost(host: SlashCommandHost): void {
'primary',
' Cost ',
);
host.state.transcriptContainer.addChild(panel);
host.state.transcriptContainer.addTranscriptChild(panel, {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
}

Expand All @@ -70,7 +73,10 @@ export async function showUsage(host: SlashCommandHost): Promise<void> {
maxContextTokens: host.state.appState.maxContextTokens,
};
const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary');
host.state.transcriptContainer.addChild(panel);
host.state.transcriptContainer.addTranscriptChild(panel, {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
}

Expand Down Expand Up @@ -110,7 +116,10 @@ export async function showContextReport(
'primary',
' Context ',
);
host.state.transcriptContainer.addChild(panel);
host.state.transcriptContainer.addTranscriptChild(panel, {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
} catch (error) {
host.showError(`Failed to load context usage: ${formatErrorMessage(error)}`);
Expand Down Expand Up @@ -139,7 +148,10 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
statusError: runtimeStatus.error,
};
const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status ');
host.state.transcriptContainer.addChild(panel);
host.state.transcriptContainer.addTranscriptChild(panel, {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
}

Expand All @@ -158,7 +170,10 @@ export async function showMcpServers(host: SlashCommandHost): Promise<void> {
'primary',
title,
);
host.state.transcriptContainer.addChild(panel);
host.state.transcriptContainer.addTranscriptChild(panel, {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
}

Expand Down
10 changes: 8 additions & 2 deletions apps/pythinker-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,10 @@ async function renderPluginsList(
'primary',
title,
);
host.state.transcriptContainer.addChild(panel);
host.state.transcriptContainer.addTranscriptChild(panel, {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
}

Expand All @@ -479,7 +482,10 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<voi
'primary',
` ${info.id} `,
);
host.state.transcriptContainer.addChild(panel);
host.state.transcriptContainer.addTranscriptChild(panel, {
role: 'ephemeral',
edgeBlankPolicy: 'preserve',
});
host.state.ui.requestRender();
}

Expand Down
22 changes: 22 additions & 0 deletions apps/pythinker-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ const FAST_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'off', description: 'Turn Fast mode off' },
{ value: 'status', description: 'Show Fast mode status' },
];
const ADVISOR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'status', description: 'Show advisor status' },
{ value: 'on', description: 'Enable the advisor' },
{ value: 'off', description: 'Disable the advisor' },
{ value: 'toggle', description: 'Toggle the advisor' },
{ value: 'reload', description: 'Reload WATCHDOG configuration' },
];

const COLORS_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'on', description: 'Keep rainbow colors on' },
Expand Down Expand Up @@ -75,6 +82,10 @@ export function dynamicWorkflowArgumentCompletions(argumentPrefix: string): Auto
export function fastArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
return completeLeadingArg(FAST_ARG_COMPLETIONS, argumentPrefix);
}
/** Argument autocompletion for the `/advisor` command. */
export function advisorArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
return completeLeadingArg(ADVISOR_ARG_COMPLETIONS, argumentPrefix);
}

/** Argument autocompletion for the `/colors` command. */
export function colorsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
Expand Down Expand Up @@ -183,6 +194,17 @@ export const BUILTIN_SLASH_COMMANDS = [
completeArgs: fastArgumentCompletions,
availability: (args) => args.trim().toLowerCase() === 'status' ? 'always' : 'idle-only',
},
{
name: 'advisor',
aliases: [],
description: 'Show or control the second-opinion advisor',
priority: 95,
completeArgs: advisorArgumentCompletions,
availability: (args) => {
const verb = args.trim().toLowerCase();
return verb === '' || verb === 'status' ? 'always' : 'idle-only';
},
},
Comment thread
elkaix marked this conversation as resolved.
{
name: 'provider',
aliases: ['providers'],
Expand Down
3 changes: 2 additions & 1 deletion apps/pythinker-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,9 +702,10 @@ function renderWelcome(host: SlashCommandHost): void {
) {
return;
}
host.state.transcriptContainer.addChild(
host.state.transcriptContainer.addTranscriptChild(
new WelcomeComponent(host.state.appState, () => {
host.state.ui.requestRender();
}),
{ role: 'ephemeral', edgeBlankPolicy: 'preserve' },
);
}
Loading
Loading