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
16 changes: 16 additions & 0 deletions console/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,16 @@
margin: 0;
}

/* Textarea mode (`format: "textarea"`): wrap instead of scroll sideways,
cap the height so a 20k-char prompt doesn't swallow the form. */
.workers-tab .env-lexical-editor--multiline {
white-space: pre-wrap;
word-break: break-word;
overflow-x: visible;
overflow-y: auto;
max-height: 24rem;
}

.workers-tab .env-lexical-placeholder {
pointer-events: none;
position: absolute;
Expand All @@ -329,6 +339,12 @@
user-select: none;
font-family: var(--font-sans);
font-size: 15px;
overflow: hidden; /* never let a long placeholder spill past the box */
}

.workers-tab .env-lexical-placeholder--multiline {
align-items: flex-start;
padding-top: 7px;
}

/* iii-styled <details> rows: hide the native marker, rotate our own chevron */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useRef } from 'react'
import { ModeToggle } from '@/components/ui/ModeToggle'
import { cn } from '@/lib/utils'
import type { JsonValue } from '../api'
import { FieldDispatch, type FieldProps } from './FieldDispatch'
import { FieldShell } from './FieldShell'
import { pathToDomId } from './path'
Expand All @@ -15,12 +17,15 @@ const MODE_OPTIONS: { value: Mode; label: string }[] = [
/**
* Wrapper for schemas declared as `type: ["X", "null"]`. Renders a small
* set/unset toggle on top of the inner field; when "unset" is selected,
* the value is forced to `null` and the inner field disappears so the
* operator isn't editing a control whose value is being ignored.
* the saved value is forced to `null` and the inner field disappears so
* the operator isn't editing a control whose value is being ignored.
*
* When the operator flips back to "set", we seed the inner field with
* the schema-provided default (or a type-appropriate zero) so they don't
* land on `null` and bounce back to the unset state on the next render.
* Flipping to "unset" does NOT discard what was typed: we stash the last
* set value in a ref and restore it when the operator flips back to "set",
* so toggling the mode is non-destructive. The first "set" (nothing
* stashed) seeds the schema default — for a provider `system_prompt`
* that's the provider-declared prompt, giving the operator an editable
* starting point instead of a blank box.
*/
export function NullableField(props: FieldProps) {
const { label, schema, value, onChange, required } = props
Expand All @@ -29,13 +34,19 @@ export function NullableField(props: FieldProps) {
const innerSchema = withoutNull(schema)
const mode: Mode = value === null || value === undefined ? 'unset' : 'set'

// Remember the last non-null value so unset→set round-trips the draft.
// Kept current on every render while set, so it captures live edits.
const lastSetRef = useRef<JsonValue | undefined>(undefined)
if (mode === 'set') lastSetRef.current = value

function handleModeChange(next: Mode) {
if (next === 'unset') {
onChange(null)
} else {
// Seed with the schema default (or type-appropriate zero) so the
// inner field has something to render once it appears.
onChange(schemaDefault(innerSchema))
// Restore the stashed draft; first time (nothing stashed) fall back
// to the schema default (the provider-declared prompt for a slice's
// system_prompt) or a type-appropriate zero.
onChange(lastSetRef.current ?? schemaDefault(innerSchema))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import { pathToDomId } from './path'
* structured strings whose grammar conflicts with `${…}` (typing a
* date and seeing a piece of it become a pill would be jarring).
*
* `format: "textarea"` renders the same pill editor in multiline mode —
* for long-form prose values (system prompts, message templates) where a
* single-line input is unusable. Pills and `${VAR}` templating work
* exactly as in the single-line editor.
*
* Single-value-enum strings hit `EnumField` via `FieldDispatch`, so we
* don't need a code branch here.
*/
Expand All @@ -41,8 +46,13 @@ export function StringField(props: FieldProps) {
const description =
typeof schema.description === 'string' ? schema.description : undefined
const format = typeof schema.format === 'string' ? schema.format : undefined
// In textarea mode the schema default is the editor's seed-on-set value
// (a full prompt), never a placeholder hint — showing it as a placeholder
// when the field is cleared would spill a multi-KB string out of the box.
const placeholder =
typeof schema.default === 'string' ? schema.default : undefined
format !== 'textarea' && typeof schema.default === 'string'
? schema.default
: undefined
const useLexical = !format || !NON_TEMPLATED_FORMATS.has(format)

return (
Expand All @@ -61,6 +71,7 @@ export function StringField(props: FieldProps) {
value={current}
onChange={onChange}
placeholder={placeholder}
multiline={format === 'textarea'}
aria-label={label}
/>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getRoot,
Expand All @@ -31,6 +32,13 @@ interface EnvLexicalInputProps {
onChange: (next: string) => void
placeholder?: string
disabled?: boolean
/**
* Textarea mode for long-form values (`format: "textarea"`): Enter
* inserts a line break instead of being swallowed, text wraps, and the
* surface grows to several rows. Newlines round-trip as `\n` — the
* single paragraph gains `LineBreakNode`s, whose text content is `\n`.
*/
multiline?: boolean
/** Forwarded to the underlying content-editable surface. */
'aria-label'?: string
}
Expand Down Expand Up @@ -64,6 +72,7 @@ export function EnvLexicalInput({
onChange,
placeholder,
disabled,
multiline,
'aria-label': ariaLabel,
}: EnvLexicalInputProps) {
// Track the value we last emitted so we can detect external changes
Expand Down Expand Up @@ -95,9 +104,9 @@ export function EnvLexicalInput({
<LexicalComposer initialConfig={initialConfig}>
<div
className={cn(
'relative w-full border border-rule bg-bg px-3 min-h-9 text-ink',
'relative w-full border border-rule bg-bg px-3 text-ink',
'focus-within:border-ink transition-colors',
'flex items-center',
multiline ? 'min-h-36 flex items-start' : 'min-h-9 flex items-center',
disabled && 'opacity-40 pointer-events-none',
)}
>
Expand All @@ -108,19 +117,27 @@ export function EnvLexicalInput({
aria-label={ariaLabel}
aria-placeholder={placeholder ?? ''}
placeholder={
<div className="env-lexical-placeholder">
<div
className={cn(
'env-lexical-placeholder',
multiline && 'env-lexical-placeholder--multiline',
)}
>
{placeholder ?? ''}
</div>
}
className="env-lexical-editor flex-1 py-[7px] outline-none"
className={cn(
'env-lexical-editor flex-1 py-[7px] outline-none',
multiline && 'env-lexical-editor--multiline',
)}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ClearEditorPlugin />
<EnvPlaceholderTransformPlugin />
<SingleLinePlugin />
{!multiline && <SingleLinePlugin />}
<EditablePlugin disabled={disabled} />
<ChangePlugin
onChange={(text) => {
Expand Down Expand Up @@ -216,16 +233,20 @@ function ExternalValueSyncPlugin({
* One-shot initializer: clear the editor and rebuild a single paragraph
* with text + env-placeholder pills based on the template string. Used
* both at mount (via `initialConfig.editorState`) and on external resync.
* Newlines inside text segments become `LineBreakNode`s so multiline
* values render as lines and still round-trip to `\n` via getTextContent.
*/
function seedEditorFromTemplate(editor: LexicalEditor, template: string) {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
for (const segment of parseTemplate(template)) {
if (segment.kind === 'text') {
if (segment.text.length > 0) {
paragraph.append($createTextNode(segment.text))
}
const lines = segment.text.split('\n')
lines.forEach((line, i) => {
if (i > 0) paragraph.append($createLineBreakNode())
if (line.length > 0) paragraph.append($createTextNode(line))
})
} else {
paragraph.append(
$createEnvPlaceholderNode(segment.variable, segment.defaultValue),
Expand Down
24 changes: 15 additions & 9 deletions harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,21 @@ retries) and their defaults live in [`src/config.rs`](src/config.rs).

## System prompt

When `options.system_prompt` is omitted (or empty), the harness assembles the
engine-grounded identity prompt at send time: four provider-specific variants
(`anthropic`, `openai` → gpt, `kimi`, and a step-by-step default for local
runtimes) selected from `provider`, plus an optional `mode` (`plan` | `ask` |
`agent`) that prepends a short operating-mode paragraph. A non-empty
`system_prompt` is combined with the default prompt per
`options.system_prompt_strategy`: `override` (default) uses it verbatim, while
`enrich` appends it to the default prompt. Prompt bodies live in
[`prompts/`](prompts/) and are tested in [`src/prompt/tests.rs`](src/prompt/tests.rs).
The identity prompt is assembled once at send/spawn time. The harness asks the
llm-router for the effective per-provider prompt (`router::system_prompt::get`
with the request's `provider`): provider workers declare their own identity
prompt at registration, and operators can override it per provider by setting
`system_prompt` in the `llm-router` configuration entry (unset = provider
default). When the router serves nothing — router absent, unknown provider,
or no declared prompt — the harness falls back to its embedded step-by-step
default prompt ([`prompts/default.txt`](prompts/default.txt)).

An optional `mode` (`plan` | `ask` | `agent`) prepends a short operating-mode
paragraph. A non-empty `options.system_prompt` is combined with the built-in
prompt per `options.system_prompt_strategy`: `enrich` (default) appends it to
the built-in prompt, while `override` uses it verbatim. Assembly is tested in
[`src/prompt/tests.rs`](src/prompt/tests.rs); provider-specific prompt bodies
live in each provider worker (`provider-*/prompts/identity.txt`).

## Custom trigger types

Expand Down
Loading
Loading