Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ For the multi-repository case the model clones what is not checked out locally,

**Scan**

Standard or Deep. The whole repository, selected paths, your uncommitted changes, or everything since a branch or commit. Set a time limit if you want one. Two scans can run at once.
Standard or Deep. The whole repository, selected paths, your uncommitted changes, or everything since a branch or commit. For each scan you can pick the reasoning effort, a cost limit and a time limit. Two scans can run at once.

**Review findings**

Expand Down
4 changes: 4 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ Tell it the window your provider gives that model, and when to compact:

Use the limit your provider reports for the model (the error message states it), and set the compaction limit to roughly 80% of it. These are native Codex settings, so they also work in `~/.codex/config.toml`, including inside a profile.

## Settings for one scan

The scan review lets you change the reasoning effort, cost limit, time limit and model for that scan only. In chat, ask for it: "scan this on low effort, stop at $5". Anything you leave alone comes from the files above.

## Time limits

`scan.maxTimeMinutes` sets how long a scan investigates. A Standard scan has no limit by default and treats the value as a target: the model paces itself, and writing the report can run past it. A Deep scan stops discovery at the limit; without one the SDK's own default applies (96 hours). You can also set it for one scan in the scan review.
Expand Down
26 changes: 22 additions & 4 deletions src/core/scan-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface ScanInputs {
contextFiles?: string[];
/** Minutes of investigation. Advisory for Standard; a discovery deadline for Deep. */
maxTimeMinutes?: number;
/** For this scan only, instead of the configured model, reasoning effort and cost limit. */
model?: string;
reasoningEffort?: string;
maxCostUsd?: number;
/** Continue an interrupted Deep scan instead of starting a new one. Other inputs are ignored. */
resumeRunId?: string;
}
Expand Down Expand Up @@ -79,6 +83,12 @@ function parseInputs(value: unknown): ScanInputs {
!['undefined', 'string'].includes(typeof data.repository) ||
!['undefined', 'string'].includes(typeof data.scanPrompt) ||
!['undefined', 'string'].includes(typeof data.changes) ||
!['undefined', 'string'].includes(typeof data.model) ||
!['undefined', 'string'].includes(typeof data.reasoningEffort) ||
!(
data.maxCostUsd === undefined ||
(typeof data.maxCostUsd === 'number' && data.maxCostUsd > 0)
) ||
!strings(data.scopePaths) ||
!strings(data.contextFiles) ||
![undefined, 'standard', 'deep'].includes(data.mode as string) ||
Expand All @@ -88,7 +98,7 @@ function parseInputs(value: unknown): ScanInputs {
)
)
throw new SecurityInputError(
'Scan inputs are repository, scopePaths, changes, scanPrompt, contextFiles, mode (standard or deep) and maxTimeMinutes.',
'Scan inputs are repository, scopePaths, changes, scanPrompt, contextFiles, mode (standard or deep), maxTimeMinutes, model, reasoningEffort and maxCostUsd.',
);
if (data.changes && (data.scopePaths as string[] | undefined)?.length)
throw new SecurityInputError('Scan either paths or changes, not both.');
Expand Down Expand Up @@ -215,11 +225,19 @@ export async function prepareScan(
const scanSettings = {
...settings.scan,
...(input.maxTimeMinutes ? { maxTimeMinutes: input.maxTimeMinutes } : {}),
...(input.maxCostUsd ? { maxCostUsd: input.maxCostUsd } : {}),
};
const codex = {
...settings.codex,
...(input.model?.trim() ? { model: input.model.trim() } : {}),
...(input.reasoningEffort?.trim()
? { model_reasoning_effort: input.reasoningEffort.trim() }
: {}),
};
if (purpose === 'validation') delete scanSettings.maxCostUsd;
const changes = input.changes?.trim() || undefined;
const target = await scanTarget({ scopePaths: input.scopePaths, changes });
return withSdk(settings.codex, async (client, sdk) => {
return withSdk(codex, async (client, sdk) => {
const preflight = await client.preflight(repository, {
...scanSettings,
auth: settings.auth,
Expand All @@ -236,11 +254,11 @@ export async function prepareScan(
scanPrompt: input.scanPrompt ?? '',
contextFiles: contextFiles.map((path) => ({ path })),
settings: {
codex: (await routeFrom(sdk, settings.codex, preflight)).route,
codex: (await routeFrom(sdk, codex, preflight)).route,
scan: { ...scanSettings, mode: preflight.mode },
},
globalConfigFile: settings.path,
sdk: { codexOverrides: settings.codex, auth: settings.auth },
sdk: { codexOverrides: codex, auth: settings.auth },
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
mock,
// No undefined members: the host only carries JSON values over RPC.
Expand Down
51 changes: 49 additions & 2 deletions src/gui/ScanComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createSignal, Show } from 'solid-js';
import { createSignal, For, Show } from 'solid-js';
import { asState } from '../types';
import type { ScanInputs, ScanPlan } from '../core/scan-plan';
import { splitPaths, userError } from '../scan-display';
import { reasoningEfforts, splitPaths, userError } from '../scan-display';
import { ScanReview } from './ScanReview';
import type { SecurityPort, SecurityStore } from './store';

Expand Down Expand Up @@ -41,6 +41,9 @@ export function ScanComposer(props: {
initial?.contextFiles.map((file) => file.path).join('\n') ?? '',
);
const [minutes, setMinutes] = createSignal(initial?.inputs.maxTimeMinutes?.toString() ?? '');
const [effort, setEffort] = createSignal(initial?.inputs.reasoningEffort ?? '');
const [cost, setCost] = createSignal(initial?.inputs.maxCostUsd?.toString() ?? '');
const [model, setModel] = createSignal(initial?.inputs.model ?? '');
const [preview, setPreview] = createSignal<ScanPlan | undefined>(
props.edit ? undefined : initial,
);
Expand Down Expand Up @@ -73,6 +76,9 @@ export function ScanComposer(props: {
contextFiles: splitPaths(documents()),
...(mode() !== 'default' ? { mode: mode() as 'standard' | 'deep' } : {}),
...(Number(minutes()) > 0 ? { maxTimeMinutes: Number(minutes()) } : {}),
...(effort() ? { reasoningEffort: effort() } : {}),
...(Number(cost()) > 0 ? { maxCostUsd: Number(cost()) } : {}),
...(model().trim() ? { model: model().trim() } : {}),
};
const next = asState(await props.port.prepare(input));
if (!next.plan) throw new Error('The server did not return a scan preview.');
Expand Down Expand Up @@ -141,6 +147,33 @@ export function ScanComposer(props: {
</select>
<span class="gs-help">Deep takes longer and costs more.</span>
</label>
<label>
Reasoning effort
<select
value={effort()}
onChange={(e) => setEffort(e.currentTarget.value)}
aria-label="Reasoning effort"
>
<option value="">Default from settings</option>
<For each={reasoningEfforts}>
{(level) => <option value={level}>{level}</option>}
</For>
</select>
<span class="gs-help">Lower is faster and cheaper; higher looks harder.</span>
</label>
<label>
Cost limit <span class="gs-muted">US dollars, optional</span>
<input
type="number"
min="0.01"
step="any"
value={cost()}
onInput={(e) => setCost(e.currentTarget.value)}
placeholder="Default from settings"
aria-label="Cost limit in US dollars"
/>
<span class="gs-help">Stops the scan when its estimated cost passes this.</span>
</label>
<label>
Time limit <span class="gs-muted">minutes, optional</span>
<input
Expand Down Expand Up @@ -209,6 +242,20 @@ export function ScanComposer(props: {
<Show when={scope() === 'uncommitted' || scope() === 'since'}>
<p class="gs-help">Change scans run at Standard depth.</p>
</Show>
<details class="gs-disclosure" open={!!model()}>
<summary>Model</summary>
<label>
Model
<input
value={model()}
onInput={(e) => setModel(e.currentTarget.value)}
placeholder="Default from settings"
aria-label="Model"
spellcheck={false}
/>
<span class="gs-help">For this scan only. Your provider must offer it.</span>
</label>
</details>
<details class="gs-disclosure">
<summary>Context files</summary>
<label>
Expand Down
15 changes: 15 additions & 0 deletions src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ export const scanInputSchema = {
description:
'Minutes to spend investigating. Standard scans have no limit unless this is set, and treat it as a target; Deep scans stop discovery at it.',
},
model: {
type: 'string',
description: 'Model for this scan only. Omit to use the configured model.',
},
reasoningEffort: {
type: 'string',
description:
'Reasoning effort for this scan only, such as low, medium, high or xhigh. Lower is faster and cheaper. Omit to use the configured effort.',
},
maxCostUsd: {
type: 'number',
exclusiveMinimum: 0,
description:
'Stop this scan when its estimated cost passes this many US dollars. Omit to use the configured limit, if any.',
},
resumeRunId: {
type: 'string',
description:
Expand Down
2 changes: 2 additions & 0 deletions src/scan-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export function timeLimitLabel(scan: { mode?: string; maxTimeMinutes?: number }
const amount = minutes >= 120 ? `${Math.round(minutes / 6) / 10}h` : `${minutes}m`;
return scan?.mode === 'deep' ? `${amount} of discovery` : `${amount} target, not a hard stop`;
}
/** Reasoning efforts offered in the review. Others can be set in config or by the scan tool. */
export const reasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const;
/** Why a scan cannot be resumed, or '' when it can. */
export function resumeProblem(run: ScanState) {
if (run.status !== 'error' && run.status !== 'cancelled')
Expand Down
54 changes: 50 additions & 4 deletions src/tui/review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import type { Plugin } from '@opencode/plugin/tui';
import { createSignal, For, Show } from 'solid-js';
import type { ScanInputs, ScanPlan } from '../core/scan-plan';
import { describeInferenceRoute, describePlan, isAbsoluteLocalDirectory } from '../plan-view';
import { scopeLabel, timeLimitLabel, userError } from '../scan-display';
import { reasoningEfforts, scopeLabel, timeLimitLabel, userError } from '../scan-display';
import type { Colors } from './theme';
import { costLine, splitList } from './text';

type Depth = 'default' | 'standard' | 'deep';
type Edit = 'target' | 'scope' | 'focus' | 'context' | 'time';
type Edit = 'target' | 'scope' | 'focus' | 'context' | 'time' | 'effort' | 'cost' | 'model';
type Action = 'start' | 'back' | Edit;

const nextDepth: Record<Depth, Depth> = { default: 'standard', standard: 'deep', deep: 'default' };
Expand Down Expand Up @@ -191,6 +191,45 @@ async function edit(ctx: Plugin.Context, field: Edit, plan: ScanPlan) {
if (!(minutes > 0)) throw new Error('Enter the time limit as a number of minutes.');
return { ...rest, maxTimeMinutes: minutes };
}
if (field === 'effort') {
const { reasoningEffort: _previous, ...rest } = inputs;
const choice = await ctx.ui.dialog.select<string>({
title: 'Reasoning effort',
current: inputs.reasoningEffort ?? '',
options: [
{ title: 'Default from settings', value: '' },
...reasoningEfforts.map((level) => ({ title: level, value: level as string })),
],
});
if (choice === undefined) return;
return choice ? { ...rest, reasoningEffort: choice } : rest;
}
if (field === 'cost') {
const value = await ctx.ui.dialog.prompt({
title: 'Cost limit',
description:
'US dollars. The scan stops when its estimated cost passes this. Leave empty for the configured limit.',
value: inputs.maxCostUsd?.toString() ?? '',
placeholder: '5',
});
if (value === undefined) return;
const { maxCostUsd: _previous, ...rest } = inputs;
if (!value.trim()) return rest;
const dollars = Number(value);
if (!(dollars > 0)) throw new Error('Enter the cost limit as a number of dollars.');
return { ...rest, maxCostUsd: dollars };
}
if (field === 'model') {
const value = await ctx.ui.dialog.prompt({
title: 'Model',
description: 'For this scan only. Leave empty for the configured model.',
value: inputs.model ?? '',
placeholder: plan.settings.codex.model,
});
if (value === undefined) return;
const { model: _previous, ...rest } = inputs;
return value.trim() ? { ...rest, model: value.trim() } : rest;
}
const value = await ctx.ui.dialog.prompt({
title: 'Context files',
description: 'Repository-relative docs to include, separated by commas.',
Expand Down Expand Up @@ -228,6 +267,9 @@ function Review(props: {
{ bind: 'f', title: 'Change focus', enabled: idle, run: () => props.onAction('focus') },
{ bind: 'c', title: 'Change context', enabled: idle, run: () => props.onAction('context') },
{ bind: 'm', title: 'Change time limit', enabled: idle, run: () => props.onAction('time') },
{ bind: 'r', title: 'Change reasoning', enabled: idle, run: () => props.onAction('effort') },
{ bind: 'l', title: 'Change cost limit', enabled: idle, run: () => props.onAction('cost') },
{ bind: 'o', title: 'Change model', enabled: idle, run: () => props.onAction('model') },
{ bind: 'i', title: 'Toggle settings', run: props.onSettings },
],
}));
Expand All @@ -238,6 +280,10 @@ function Review(props: {
['Depth', depth()],
['Scope', scopeLabel(props.plan)],
['Time limit', timeLimitLabel(props.plan.settings.scan)],
[
'Model',
`${props.plan.settings.codex.model} · ${props.plan.settings.codex.reasoningEffort} reasoning`,
],
['Focus', props.plan.scanPrompt || '—'],
['Context', props.plan.contextFiles.map((file) => file.path).join(', ') || '—'],
['Route', describeInferenceRoute(props.plan.settings)],
Expand Down Expand Up @@ -278,8 +324,8 @@ function Review(props: {
<text fg={props.color.muted()}>{describePlan(props.plan)}</text>
</Show>
<text fg={props.color.muted()}>
y start · t target · d depth · s scope · m time limit · f focus · c context · i settings ·
esc back
y start · t target · d depth · s scope · r reasoning · l cost limit · m time limit · o model
· f focus · c context · i settings · esc back
</text>
</box>
);
Expand Down
19 changes: 19 additions & 0 deletions tests/scan-plan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,22 @@ test('a time limit chosen for one scan reaches the SDK settings', async (t) => {
assert.equal((await prepareScan(root, {})).settings.scan.maxTimeMinutes, undefined);
await assert.rejects(prepareScan(root, { maxTimeMinutes: 0 }), /maxTimeMinutes/);
});

test('model, reasoning effort and cost limit can be chosen for one scan', async (t) => {
const root = await project(t);
const usual = await prepareScan(root, {});
const chosen = await prepareScan(root, {
model: 'gpt-5.6-terra',
reasoningEffort: 'low',
maxCostUsd: 2,
});
assert.equal(chosen.settings.codex.model, 'gpt-5.6-terra');
assert.equal(chosen.settings.codex.reasoningEffort, 'low');
assert.equal(chosen.settings.scan.maxCostUsd, 2);
assert.equal(chosen.sdk.codexOverrides.model_reasoning_effort, 'low');
// Kept in the inputs, so running the scan again or editing it carries them over.
assert.equal(chosen.inputs.reasoningEffort, 'low');
assert.notEqual(usual.settings.codex.reasoningEffort, 'low');
assert.equal(usual.settings.scan.maxCostUsd, undefined);
await assert.rejects(prepareScan(root, { maxCostUsd: 0 }), /maxCostUsd/);
});