mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
feat(eve): tui - simplify model selection and activity feedback (#3548)
Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"eve": patch
|
||||
---
|
||||
|
||||
Choose a model, speed, and reasoning through separate steps in the terminal UI, with clear defaults and changes applied together after the final choice. The slash menu now puts model selection and session controls first, and a steady Thinking, Generating, or Running label with a blinking dot replaces the animated Working label during turns.
|
||||
+22
-10
@@ -17,19 +17,19 @@ The transcript remains in your terminal scrollback after you exit. Run `/help` i
|
||||
|
||||
| Command | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/login` | Connect a ChatGPT subscription, Vercel account, or provider API key. |
|
||||
| `/model` | Choose the model and its settings. Pass a model ID to set it directly: `/model provider/model-id`. |
|
||||
| `/add` | Select and install channels, MCP connections, extensions, and observability integrations. Pass an item address to install it directly: `/add channel/slack`. |
|
||||
| `/deploy` | Deploy the agent to Vercel production. Installs the Vercel CLI, signs in, and links the directory if needed. |
|
||||
| `/info` | Show the resolved application, compiled artifacts, discovery diagnostics, and messaging routes. |
|
||||
| `/loglevel` | Choose which server and agent logs appear in the transcript. |
|
||||
| `/traces` | Open the local trace viewer. Pass a trace ID prefix to open a specific trace. |
|
||||
| `/model` | Choose the model, speed, and reasoning. Pass a model ID to set it directly: `/model provider/model-id`. |
|
||||
| `/reset` | Start a fresh session. |
|
||||
| `/cancel` | Cancel the current turn without discarding settled context. |
|
||||
| `/clear` | Clear the session's model-message history. `/new` is an alias. |
|
||||
| `/compact` | Compact the current session's context. |
|
||||
| `/exit` | Quit the UI. |
|
||||
| `/cancel` | Cancel the current turn without discarding settled context. |
|
||||
| `/login` | Connect a ChatGPT subscription, Vercel account, or provider API key. |
|
||||
| `/add` | Select and install channels, MCP connections, extensions, and observability integrations. Pass an item address to install it directly: `/add channel/slack`. |
|
||||
| `/deploy` | Deploy the agent to Vercel production. Installs the Vercel CLI, signs in, and links the directory if needed. |
|
||||
| `/traces` | Open the local trace viewer. Pass a trace ID prefix to open a specific trace. |
|
||||
| `/loglevel` | Choose which server and agent logs appear in the transcript. |
|
||||
| `/info` | Show the resolved application, compiled artifacts, discovery diagnostics, and messaging routes. |
|
||||
| `/help` | List available commands. |
|
||||
| `/exit` | Quit the UI. |
|
||||
|
||||
`/login`, `/model`, `/add`, `/deploy`, `/info`, and `/traces` are available when `eve dev` runs locally. They are unavailable when the UI connects to a server with `--url`.
|
||||
|
||||
@@ -59,7 +59,17 @@ Local discovery runs only in development. Deployments need explicitly provisione
|
||||
|
||||
### Models and settings
|
||||
|
||||
`/model` opens the model picker and settings. Each completed selection applies immediately and returns to chat; there is no final Done step. A successful login or model change takes effect on the next prompt.
|
||||
`/model` walks through model, speed, and reasoning in order:
|
||||
|
||||
1. Choose a model. Type to filter the list.
|
||||
2. Choose **Standard** or **Fast** speed, when supported.
|
||||
3. Choose a reasoning level, when the model supports reasoning settings. **Provider default** leaves the reasoning level to the provider.
|
||||
|
||||
The picker highlights your current settings when they are compatible with the selected model and skips settings that cannot be changed. Use `↑` and `↓` to move, then `Enter` to advance or apply the final choice. `Esc` or `←` returns to the previous step; at the model list, either key cancels. `Ctrl+C` cancels from any step.
|
||||
|
||||
Changes apply together after the final choice, then the picker returns to chat. Cancelling leaves your model and settings unchanged.
|
||||
|
||||
A successful login or model change takes effect on the next prompt.
|
||||
|
||||
OpenAI, ChatGPT, and Gateway connections default to `gpt-5.6-luna-fast`; Anthropic defaults to `claude-sonnet-5`. An explicitly authored compatible model stays selected. If a new default is unavailable, eve offers the connection's available models. Dynamic or custom model expressions must be edited in `agent.ts`.
|
||||
|
||||
@@ -82,6 +92,8 @@ Required authorization or deployment setup still runs for the selected item. Pre
|
||||
|
||||
Type a message and press `Enter` to send it. When the agent asks a question or requests tool approval, respond in the prompt shown by the UI. Connection authorization can open a browser; keep local `eve dev` running until the browser returns to it.
|
||||
|
||||
The activity line shows **Thinking** while the model reasons or waits to respond, **Generating** while it writes a response or tool input, and **Running** while tools execute. A blinking dot and elapsed time indicate progress, with token counts shown when available. The activity line disappears when the turn finishes or needs your input.
|
||||
|
||||
While a turn is running, `Enter` sends your message immediately as steering. Before assistant output begins, the runtime interrupts pending model generation and continues the same turn with your correction. Executing tools finish safely. After output begins, steering applies at the next workflow boundary and preserves streamed text.
|
||||
|
||||
Slash commands wait until the turn ends, except `/cancel`, which cancels directly. If the session does not support steering, messages queue for the next turn. Press `Esc` or `Ctrl+C` to cancel a turn with no queued messages. With queued messages, these keys select the oldest message for steering, or for the next turn if steering is unavailable. If a direct cancellation requested with `/cancel` or `Ctrl+C` does not settle, press `Ctrl+C` to stop waiting. The UI then returns to the prompt and asks you to press `Ctrl+C` again to exit. At an idle prompt, press `Ctrl+C` twice to exit.
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("renderCommandSuggestions", () => {
|
||||
const state = typeaheadFor(PROMPT_COMMANDS, "/");
|
||||
const rows = renderCommandSuggestions(state, theme, 80).map(stripAnsi);
|
||||
expect(rows).toHaveLength(PROMPT_COMMANDS.length);
|
||||
expect(rows[0]).toContain("/help");
|
||||
expect(rows[0]).toContain("/model");
|
||||
});
|
||||
|
||||
it("clips rows to the terminal width", () => {
|
||||
@@ -192,10 +192,10 @@ describe("renderCommandSuggestions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the real registry on a bare slash with /help leading", () => {
|
||||
it("renders the real registry on a bare slash with /model leading", () => {
|
||||
const state = typeaheadFor(PROMPT_COMMANDS, "/");
|
||||
const rows = renderCommandSuggestions(state, theme, 80).map(stripAnsi);
|
||||
expect(rows[0]).toContain("/help");
|
||||
expect(rows[0]).toContain("/model");
|
||||
expect(rows[0]).toContain(theme.glyph.selectedPointer);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelSettingsRequest } from "#setup/flows/model.js";
|
||||
import {
|
||||
initialModelEditorState,
|
||||
modelEditorMenuRows,
|
||||
transitionModelEditor,
|
||||
type ModelEditorState,
|
||||
type ModelEditorEvent,
|
||||
} from "./model-editor.js";
|
||||
const request: ModelSettingsRequest = {
|
||||
model: {
|
||||
kind: "pick",
|
||||
current: "openai/gpt-5.6-luna-fast",
|
||||
options: [
|
||||
{ value: "openai/gpt-5.6-luna-fast", label: "Luna" },
|
||||
{ value: "anthropic/claude-sonnet-5", label: "Sonnet" },
|
||||
{ value: "test/no-reasoning", label: "Simple" },
|
||||
],
|
||||
},
|
||||
reasoning: "high",
|
||||
serviceTier: { kind: "priority" },
|
||||
settingsEditable: true,
|
||||
externalRouting: false,
|
||||
capabilitiesFor: (id) =>
|
||||
id === "test/no-reasoning"
|
||||
? { reasoning: false, reasoningLevels: [], fastMode: false }
|
||||
: { reasoning: true, reasoningLevels: ["low", "high"], fastMode: true },
|
||||
};
|
||||
function drive(events: ModelEditorEvent[], req = request): ModelEditorState {
|
||||
let state = initialModelEditorState(req);
|
||||
for (const event of events) {
|
||||
const result = transitionModelEditor(state, event, req);
|
||||
if (result.kind !== "render" && result.kind !== "ignore")
|
||||
throw new Error(`Unexpected ${result.kind}`);
|
||||
state = result.state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
describe("model editor", () => {
|
||||
it("has model, reasoning and tier settings without a Done row", () => {
|
||||
const state = initialModelEditorState(request);
|
||||
expect(
|
||||
modelEditorMenuRows(request, state.draft, state.capabilities).map((row) => row.value),
|
||||
).toEqual(["model", "reasoning", "tier"]);
|
||||
});
|
||||
it("applies a filtered model on Enter", () => {
|
||||
const state = drive([{ type: "submit" }, { type: "char", char: "Sonnet" }]);
|
||||
expect(transitionModelEditor(state, { type: "submit" }, request)).toEqual({
|
||||
kind: "settle",
|
||||
result: { model: "anthropic/claude-sonnet-5" },
|
||||
});
|
||||
});
|
||||
it("clears a filter before returning to settings on Esc", () => {
|
||||
const state = drive([
|
||||
{ type: "submit" },
|
||||
{ type: "char", char: "Luna" },
|
||||
{ type: "cancel" },
|
||||
{ type: "cancel" },
|
||||
]);
|
||||
expect(state.screen).toEqual({ kind: "menu", cursor: "model" });
|
||||
expect(transitionModelEditor(state, { type: "cancel" }, request)).toEqual({ kind: "cancel" });
|
||||
});
|
||||
it("does not submit a filter with no matches", () => {
|
||||
const state = drive([{ type: "submit" }, { type: "char", char: "missing" }]);
|
||||
expect(transitionModelEditor(state, { type: "submit" }, request).kind).toBe("ignore");
|
||||
});
|
||||
it("applies a reasoning choice on Enter", () => {
|
||||
const state = drive([
|
||||
{ type: "move", direction: "down" },
|
||||
{ type: "adjust", direction: "left" },
|
||||
]);
|
||||
expect(transitionModelEditor(state, { type: "submit" }, request)).toEqual({
|
||||
kind: "settle",
|
||||
result: { reasoning: "low" },
|
||||
});
|
||||
});
|
||||
it("applies a service tier choice on Enter", () => {
|
||||
const state = drive([
|
||||
{ type: "move", direction: "down" },
|
||||
{ type: "move", direction: "down" },
|
||||
{ type: "adjust", direction: "left" },
|
||||
]);
|
||||
expect(transitionModelEditor(state, { type: "submit" }, request)).toEqual({
|
||||
kind: "settle",
|
||||
result: { serviceTier: "standard" },
|
||||
});
|
||||
});
|
||||
it("drops incompatible reasoning and priority tier when changing models", () => {
|
||||
const state = drive([{ type: "submit" }, { type: "char", char: "Simple" }]);
|
||||
expect(transitionModelEditor(state, { type: "submit" }, request)).toEqual({
|
||||
kind: "settle",
|
||||
result: { model: "test/no-reasoning", reasoning: "default", serviceTier: "standard" },
|
||||
});
|
||||
});
|
||||
it("leaves an SDK model fixed while allowing supported settings", () => {
|
||||
const req: ModelSettingsRequest = {
|
||||
...request,
|
||||
model: { kind: "fixed", current: "custom", reason: "Authored in agent.ts" },
|
||||
};
|
||||
const state = initialModelEditorState(req);
|
||||
expect(state.screen).toEqual({ kind: "menu", cursor: "reasoning" });
|
||||
expect(modelEditorMenuRows(req, state.draft, state.capabilities)[0]?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,392 +0,0 @@
|
||||
import { ALL_REASONING_LEVELS, type ReasoningLevel } from "#setup/boxes/model-capabilities.js";
|
||||
import type { GatewayModelCapabilities } from "#setup/boxes/model-capabilities.js";
|
||||
import {
|
||||
filterOptions,
|
||||
initialSelectState,
|
||||
reduceSelect,
|
||||
selectValueAtCursor,
|
||||
type SelectState,
|
||||
} from "#setup/cli/select-state.js";
|
||||
import type { ModelSettingsRequest, ModelSettingsResult } from "#setup/flows/model.js";
|
||||
import type { SelectOption } from "#setup/prompter.js";
|
||||
|
||||
/** The Change-model menu's rows, in visual order. */
|
||||
export type ModelEditorRowId = "model" | "reasoning" | "tier" | "done";
|
||||
|
||||
/**
|
||||
* The screen stack, depth two: the value menu, or the model list it opens.
|
||||
* Reasoning and tier adjust inline on their menu rows — only the catalog pick
|
||||
* warrants its own screen. The list carries scratch select state seeded when
|
||||
* opened; Esc drops it, so "returns unchanged" is structural.
|
||||
*/
|
||||
export type ModelEditorScreen =
|
||||
| { kind: "menu"; cursor: ModelEditorRowId }
|
||||
| { kind: "model"; select: SelectState };
|
||||
|
||||
/** The in-progress values; a completed selection is applied immediately. */
|
||||
export interface ModelEditorDraft {
|
||||
modelId: string | null;
|
||||
reasoning: "default" | ReasoningLevel;
|
||||
tier: "standard" | "priority";
|
||||
}
|
||||
|
||||
/** One Change-model interaction, advanced by {@link transitionModelEditor}. */
|
||||
export interface ModelEditorState {
|
||||
screen: ModelEditorScreen;
|
||||
draft: ModelEditorDraft;
|
||||
/** Catalog capabilities for `draft.modelId`; recomputed on every pick. */
|
||||
capabilities: GatewayModelCapabilities | undefined;
|
||||
}
|
||||
|
||||
/** Semantic input after terminal-key decoding. */
|
||||
export type ModelEditorEvent =
|
||||
| { type: "move"; direction: "up" | "down" }
|
||||
| { type: "adjust"; direction: "left" | "right" }
|
||||
| { type: "char"; char: string }
|
||||
| { type: "backspace" }
|
||||
| { type: "cancel" }
|
||||
| { type: "submit" };
|
||||
|
||||
export type ModelEditorTransition =
|
||||
| { kind: "ignore"; state: ModelEditorState }
|
||||
| { kind: "render"; state: ModelEditorState }
|
||||
| { kind: "cancel" }
|
||||
| { kind: "settle"; result: ModelSettingsResult };
|
||||
|
||||
/**
|
||||
* How a value row presents: an adjustable control, a dim explanatory line the
|
||||
* cursor skips, or nothing at all — a control that cannot apply to the
|
||||
* drafted model (no priority tier) is noise, while an authored-but-unowned
|
||||
* state (custom tier, external provider) still explains itself.
|
||||
*/
|
||||
export type ModelEditorSectionView =
|
||||
| { kind: "interactive" }
|
||||
| { kind: "static"; text: string }
|
||||
| { kind: "hidden" };
|
||||
|
||||
const NOT_EDITABLE_TEXT = "No editable agent.ts config object is available";
|
||||
|
||||
/**
|
||||
* The reasoning row stays interactive while a level is drafted even when the
|
||||
* catalog disowns reasoning, so the authored value at least stays visible.
|
||||
*/
|
||||
export function reasoningSectionView(
|
||||
request: ModelSettingsRequest,
|
||||
draft: ModelEditorDraft,
|
||||
capabilities: GatewayModelCapabilities | undefined,
|
||||
): ModelEditorSectionView {
|
||||
if (!request.settingsEditable) return { kind: "static", text: NOT_EDITABLE_TEXT };
|
||||
if (capabilities?.reasoning === false && draft.reasoning === "default") {
|
||||
return { kind: "static", text: "Not supported by the selected model" };
|
||||
}
|
||||
return { kind: "interactive" };
|
||||
}
|
||||
|
||||
/** The tier row's presentation; static or hidden for every state it cannot own. */
|
||||
export function tierSectionView(
|
||||
request: ModelSettingsRequest,
|
||||
capabilities: GatewayModelCapabilities | undefined,
|
||||
): ModelEditorSectionView {
|
||||
if (!request.settingsEditable) return { kind: "static", text: NOT_EDITABLE_TEXT };
|
||||
if (request.externalRouting) {
|
||||
return { kind: "static", text: "Disabled for a direct external provider" };
|
||||
}
|
||||
if (request.serviceTier.kind === "custom") {
|
||||
return { kind: "static", text: `Custom (${request.serviceTier.value}) — authored in agent.ts` };
|
||||
}
|
||||
// Pure availability: a model the catalog prices no priority tier for never
|
||||
// shows the row, and a model with one always does — the drafted value has
|
||||
// no say, so the row cannot vanish underneath its own toggle.
|
||||
if (capabilities !== undefined && !capabilities.fastMode) {
|
||||
return { kind: "hidden" };
|
||||
}
|
||||
return { kind: "interactive" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The track's notch positions: the levels the catalog supports (every level
|
||||
* when capabilities are unknown). The provider default is not a notch — an
|
||||
* unset draft renders an empty track and the first right-adjust enters the
|
||||
* scale at the lowest level. A drafted level the catalog does not list is
|
||||
* inserted in canonical level order, so the track never hides the value it
|
||||
* is about to replace.
|
||||
*/
|
||||
export function reasoningPositions(
|
||||
capabilities: GatewayModelCapabilities | undefined,
|
||||
drafted: "default" | ReasoningLevel,
|
||||
): readonly ReasoningLevel[] {
|
||||
const levels = capabilities === undefined ? ALL_REASONING_LEVELS : capabilities.reasoningLevels;
|
||||
const positions: ReasoningLevel[] = [...levels];
|
||||
if (drafted !== "default" && !positions.includes(drafted)) {
|
||||
const rank = ALL_REASONING_LEVELS.indexOf(drafted);
|
||||
const insertAt = positions.findIndex(
|
||||
(position) => ALL_REASONING_LEVELS.indexOf(position) > rank,
|
||||
);
|
||||
if (insertAt === -1) positions.push(drafted);
|
||||
else positions.splice(insertAt, 0, drafted);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drafted level a freshly-picked model can actually serve: kept when the
|
||||
* new model supports it, snapped to the closest supported level by canonical
|
||||
* rank when it does not (ties resolve to the cheaper level), and cleared to
|
||||
* the provider default when the model has no adjustable reasoning at all. An
|
||||
* unset draft stays unset.
|
||||
*/
|
||||
function snapReasoningToCapabilities(
|
||||
drafted: "default" | ReasoningLevel,
|
||||
capabilities: GatewayModelCapabilities | undefined,
|
||||
): "default" | ReasoningLevel {
|
||||
if (drafted === "default" || capabilities === undefined) return drafted;
|
||||
const levels = capabilities.reasoningLevels;
|
||||
if (!capabilities.reasoning || levels.length === 0) return "default";
|
||||
if (levels.includes(drafted)) return drafted;
|
||||
const rank = ALL_REASONING_LEVELS.indexOf(drafted);
|
||||
let closest = levels[0]!;
|
||||
for (const level of levels) {
|
||||
const distance = Math.abs(ALL_REASONING_LEVELS.indexOf(level) - rank);
|
||||
const best = Math.abs(ALL_REASONING_LEVELS.indexOf(closest) - rank);
|
||||
if (distance < best) closest = level;
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value menu's rows, derived fresh from the draft on every transition and
|
||||
* paint: interactive sections are pickable rows, static sections are disabled
|
||||
* rows carrying their explanation, and a hidden tier is omitted entirely.
|
||||
* Value hints are the painter's job — it styles them with theme glyphs.
|
||||
*/
|
||||
export function modelEditorMenuRows(
|
||||
request: ModelSettingsRequest,
|
||||
draft: ModelEditorDraft,
|
||||
capabilities: GatewayModelCapabilities | undefined,
|
||||
): SelectOption<ModelEditorRowId>[] {
|
||||
const rows: SelectOption<ModelEditorRowId>[] = [];
|
||||
if (request.model.kind === "pick") {
|
||||
rows.push({ value: "model", label: "Model" });
|
||||
} else {
|
||||
rows.push({
|
||||
value: "model",
|
||||
label: "Model",
|
||||
disabled: true,
|
||||
description: request.model.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const reasoning = reasoningSectionView(request, draft, capabilities);
|
||||
if (reasoning.kind === "static") {
|
||||
rows.push({
|
||||
value: "reasoning",
|
||||
label: "Reasoning effort",
|
||||
disabled: true,
|
||||
description: reasoning.text,
|
||||
});
|
||||
} else {
|
||||
rows.push({ value: "reasoning", label: "Reasoning effort" });
|
||||
}
|
||||
|
||||
const tier = tierSectionView(request, capabilities);
|
||||
if (tier.kind === "interactive") {
|
||||
rows.push({ value: "tier", label: "Service tier" });
|
||||
} else if (tier.kind === "static") {
|
||||
rows.push({ value: "tier", label: "Service tier", disabled: true, description: tier.text });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Creates the screen's state; the cursor opens on the first pickable row. */
|
||||
export function initialModelEditorState(request: ModelSettingsRequest): ModelEditorState {
|
||||
const draft: ModelEditorDraft = {
|
||||
modelId: request.model.current,
|
||||
reasoning: request.reasoning ?? "default",
|
||||
tier: request.serviceTier.kind === "priority" ? "priority" : "standard",
|
||||
};
|
||||
const capabilities = request.capabilitiesFor(draft.modelId);
|
||||
const rows = modelEditorMenuRows(request, draft, capabilities);
|
||||
const cursor = rows.find((row) => row.disabled !== true)?.value ?? "done";
|
||||
return { screen: { kind: "menu", cursor }, draft, capabilities };
|
||||
}
|
||||
|
||||
/** A fresh search list opened on the drafted model with no filter. */
|
||||
function expandedModelSelect(request: ModelSettingsRequest, modelId: string | null): SelectState {
|
||||
const options = request.model.kind === "pick" ? request.model.options : [];
|
||||
const input: Parameters<typeof initialSelectState>[0] = { options };
|
||||
if (modelId !== null) input.defaultValue = modelId;
|
||||
return initialSelectState(input);
|
||||
}
|
||||
|
||||
function ignore(state: ModelEditorState): ModelEditorTransition {
|
||||
return { kind: "ignore", state };
|
||||
}
|
||||
|
||||
function toMenu(state: ModelEditorState, cursor: ModelEditorRowId): ModelEditorTransition {
|
||||
return { kind: "render", state: { ...state, screen: { kind: "menu", cursor } } };
|
||||
}
|
||||
|
||||
/** The settle payload: only the fields that differ from the authored values. */
|
||||
function settleResult(request: ModelSettingsRequest, draft: ModelEditorDraft): ModelSettingsResult {
|
||||
const result: ModelSettingsResult = {};
|
||||
if (
|
||||
request.model.kind === "pick" &&
|
||||
draft.modelId !== null &&
|
||||
draft.modelId !== request.model.current
|
||||
) {
|
||||
result.model = draft.modelId;
|
||||
}
|
||||
if (!request.settingsEditable) return result;
|
||||
if (draft.reasoning !== (request.reasoning ?? "default")) result.reasoning = draft.reasoning;
|
||||
const tierOwned = !request.externalRouting && request.serviceTier.kind !== "custom";
|
||||
const authoredTier = request.serviceTier.kind === "priority" ? "priority" : "standard";
|
||||
if (tierOwned && draft.tier !== authoredTier) result.serviceTier = draft.tier;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** One inline left/right adjustment of the row under the menu cursor. */
|
||||
function adjustMenuValue(
|
||||
state: ModelEditorState,
|
||||
request: ModelSettingsRequest,
|
||||
cursor: ModelEditorRowId,
|
||||
direction: "left" | "right",
|
||||
): ModelEditorTransition {
|
||||
const delta = direction === "left" ? -1 : 1;
|
||||
if (cursor === "reasoning") {
|
||||
if (reasoningSectionView(request, state.draft, state.capabilities).kind !== "interactive") {
|
||||
return ignore(state);
|
||||
}
|
||||
const positions = reasoningPositions(state.capabilities, state.draft.reasoning);
|
||||
if (positions.length === 0) return ignore(state);
|
||||
// The provider default is not a notch: right enters the ring at the lowest
|
||||
// level and left at the highest. On the ring, both directions wrap.
|
||||
const index =
|
||||
state.draft.reasoning === "default" ? -1 : positions.indexOf(state.draft.reasoning);
|
||||
const next =
|
||||
index === -1
|
||||
? positions[delta === 1 ? 0 : positions.length - 1]!
|
||||
: positions[(index + delta + positions.length) % positions.length]!;
|
||||
if (next === state.draft.reasoning) return ignore(state);
|
||||
return { kind: "render", state: { ...state, draft: { ...state.draft, reasoning: next } } };
|
||||
}
|
||||
if (cursor === "tier") {
|
||||
if (tierSectionView(request, state.capabilities).kind !== "interactive") {
|
||||
return ignore(state);
|
||||
}
|
||||
// A two-value ring: either arrow (and Tab) flips the tier.
|
||||
const next = state.draft.tier === "priority" ? "standard" : "priority";
|
||||
return { kind: "render", state: { ...state, draft: { ...state.draft, tier: next } } };
|
||||
}
|
||||
return ignore(state);
|
||||
}
|
||||
|
||||
function transitionMenu(
|
||||
state: ModelEditorState,
|
||||
event: ModelEditorEvent,
|
||||
request: ModelSettingsRequest,
|
||||
cursor: ModelEditorRowId,
|
||||
): ModelEditorTransition {
|
||||
const rows = modelEditorMenuRows(request, state.draft, state.capabilities);
|
||||
|
||||
switch (event.type) {
|
||||
case "cancel":
|
||||
return { kind: "cancel" };
|
||||
case "move": {
|
||||
// Shared select stepping over the derived rows: wraps, skips disabled.
|
||||
const index = Math.max(
|
||||
0,
|
||||
rows.findIndex((row) => row.value === cursor),
|
||||
);
|
||||
const stepped = reduceSelect(
|
||||
{ filter: "", cursor: index, selected: new Set() },
|
||||
{ type: event.direction },
|
||||
{ options: rows },
|
||||
);
|
||||
const next = rows[stepped.cursor]?.value;
|
||||
if (next === undefined || next === cursor) return ignore(state);
|
||||
return toMenu(state, next);
|
||||
}
|
||||
case "adjust":
|
||||
return adjustMenuValue(state, request, cursor, event.direction);
|
||||
case "submit": {
|
||||
const row = rows.find((entry) => entry.value === cursor);
|
||||
if (row === undefined || row.disabled === true) return ignore(state);
|
||||
if (cursor === "model") {
|
||||
return {
|
||||
kind: "render",
|
||||
state: {
|
||||
...state,
|
||||
screen: { kind: "model", select: expandedModelSelect(request, state.draft.modelId) },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (cursor === "done") {
|
||||
return { kind: "settle", result: settleResult(request, state.draft) };
|
||||
}
|
||||
return { kind: "settle", result: settleResult(request, state.draft) };
|
||||
}
|
||||
case "char":
|
||||
case "backspace":
|
||||
return ignore(state);
|
||||
}
|
||||
}
|
||||
|
||||
function transitionModelScreen(
|
||||
state: ModelEditorState,
|
||||
event: ModelEditorEvent,
|
||||
request: ModelSettingsRequest,
|
||||
select: SelectState,
|
||||
): ModelEditorTransition {
|
||||
const options = request.model.kind === "pick" ? request.model.options : [];
|
||||
const renderSelect = (next: SelectState): ModelEditorTransition =>
|
||||
next === select
|
||||
? ignore(state)
|
||||
: { kind: "render", state: { ...state, screen: { kind: "model", select: next } } };
|
||||
|
||||
switch (event.type) {
|
||||
case "cancel":
|
||||
if (select.filter.length > 0) {
|
||||
return renderSelect(reduceSelect(select, { type: "clear" }, { options }));
|
||||
}
|
||||
return toMenu(state, "model");
|
||||
case "move":
|
||||
return renderSelect(reduceSelect(select, { type: event.direction }, { options }));
|
||||
case "char":
|
||||
return renderSelect(reduceSelect(select, { type: "char", char: event.char }, { options }));
|
||||
case "backspace":
|
||||
return renderSelect(reduceSelect(select, { type: "backspace" }, { options }));
|
||||
case "submit": {
|
||||
const visible = filterOptions(options, select.filter);
|
||||
const value = selectValueAtCursor(visible, select.cursor);
|
||||
if (value === undefined) return ignore(state);
|
||||
const capabilities = request.capabilitiesFor(value);
|
||||
return {
|
||||
kind: "settle",
|
||||
result: settleResult(request, {
|
||||
...state.draft,
|
||||
modelId: value,
|
||||
reasoning: snapReasoningToCapabilities(state.draft.reasoning, capabilities),
|
||||
tier:
|
||||
capabilities !== undefined && !capabilities.fastMode ? "standard" : state.draft.tier,
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "adjust":
|
||||
return ignore(state);
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies one keypress worth of semantics; terminal resources stay in the renderer. */
|
||||
export function transitionModelEditor(
|
||||
state: ModelEditorState,
|
||||
event: ModelEditorEvent,
|
||||
request: ModelSettingsRequest,
|
||||
): ModelEditorTransition {
|
||||
switch (state.screen.kind) {
|
||||
case "menu":
|
||||
return transitionMenu(state, event, request, state.screen.cursor);
|
||||
case "model":
|
||||
return transitionModelScreen(state, event, request, state.screen.select);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { ALL_REASONING_LEVELS, type ReasoningLevel } from "#setup/boxes/model-capabilities.js";
|
||||
import {
|
||||
filterOptions,
|
||||
initialSelectState,
|
||||
reduceSelect,
|
||||
selectValueAtCursor,
|
||||
type SelectState,
|
||||
} from "#setup/cli/select-state.js";
|
||||
import type { ModelSettingsRequest, ModelSettingsResult } from "#setup/flows/model.js";
|
||||
import type { SelectOption } from "#setup/prompter.js";
|
||||
|
||||
import { renderSelectQuestion } from "./setup-panel.js";
|
||||
import type { Theme } from "./theme.js";
|
||||
|
||||
type ModelPickerStep = "model" | "speed" | "reasoning";
|
||||
|
||||
interface ModelPickerDraft {
|
||||
model: string | null;
|
||||
speed: "standard" | "priority";
|
||||
reasoning: "default" | ReasoningLevel;
|
||||
}
|
||||
|
||||
interface ModelPickerPage {
|
||||
step: ModelPickerStep;
|
||||
select: SelectState;
|
||||
draft: ModelPickerDraft;
|
||||
}
|
||||
|
||||
export interface ModelPickerState extends ModelPickerPage {
|
||||
history: readonly Pick<ModelPickerPage, "step" | "select">[];
|
||||
}
|
||||
|
||||
export type ModelPickerEvent =
|
||||
| { type: "up" }
|
||||
| { type: "down" }
|
||||
| { type: "back" }
|
||||
| { type: "cancel" }
|
||||
| { type: "submit" }
|
||||
| { type: "backspace" }
|
||||
| { type: "char"; char: string };
|
||||
|
||||
export type ModelPickerTransition =
|
||||
| { kind: "render"; state: ModelPickerState }
|
||||
| { kind: "cancel" }
|
||||
| { kind: "settle"; result: ModelSettingsResult };
|
||||
|
||||
const SPEED_OPTIONS: readonly SelectOption<ModelPickerDraft["speed"]>[] = [
|
||||
{ value: "standard", label: "Standard", hint: "Normal speed and pricing" },
|
||||
{ value: "priority", label: "Fast", hint: "Priority processing, higher cost" },
|
||||
];
|
||||
|
||||
const REASONING_COPY: Record<ReasoningLevel, { label: string; hint: string }> = {
|
||||
none: { label: "None", hint: "No reasoning" },
|
||||
minimal: { label: "Minimal", hint: "The least thinking" },
|
||||
low: { label: "Low", hint: "Less thinking, quicker answers" },
|
||||
medium: { label: "Medium", hint: "Balance thinking and response time" },
|
||||
high: { label: "High", hint: "More thinking for harder tasks" },
|
||||
xhigh: { label: "Extra high", hint: "The most thinking, slower answers" },
|
||||
};
|
||||
|
||||
const REASONING_OPTIONS: readonly SelectOption<ModelPickerDraft["reasoning"]>[] = [
|
||||
{ value: "default", label: "Provider default", hint: "Use the model's default reasoning" },
|
||||
...ALL_REASONING_LEVELS.map((value) => ({ value, ...REASONING_COPY[value] })),
|
||||
];
|
||||
|
||||
function modelOptions(request: ModelSettingsRequest): readonly SelectOption<string>[] {
|
||||
if (request.model.kind === "fixed") {
|
||||
return [{ value: "fixed", label: request.model.current ?? "Configured in agent.ts" }];
|
||||
}
|
||||
const { options, current } = request.model;
|
||||
if (current === null || options.some((option) => option.value === current)) return options;
|
||||
// A saved model can outlive its catalog listing; Enter must not silently replace it.
|
||||
return [{ value: current, label: current, hint: "Current model" }, ...options];
|
||||
}
|
||||
|
||||
function stepOptions(
|
||||
request: ModelSettingsRequest,
|
||||
draft: ModelPickerDraft,
|
||||
step: ModelPickerStep,
|
||||
): readonly SelectOption<string>[] {
|
||||
switch (step) {
|
||||
case "model":
|
||||
return modelOptions(request);
|
||||
case "speed":
|
||||
return SPEED_OPTIONS;
|
||||
case "reasoning": {
|
||||
const levels = reasoningLevels(request, draft);
|
||||
return REASONING_OPTIONS.filter(
|
||||
(option) => option.value === "default" || levels.includes(option.value),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reasoningLevels(
|
||||
request: ModelSettingsRequest,
|
||||
draft: ModelPickerDraft,
|
||||
): readonly ReasoningLevel[] {
|
||||
const capabilities = request.capabilitiesFor(draft.model);
|
||||
if (capabilities !== undefined) return capabilities.reasoning ? capabilities.reasoningLevels : [];
|
||||
// Without catalog evidence, only keep or clear a setting already authored for this model.
|
||||
return draft.model === request.model.current && request.reasoning !== null
|
||||
? [request.reasoning]
|
||||
: [];
|
||||
}
|
||||
|
||||
function nextStep(
|
||||
request: ModelSettingsRequest,
|
||||
draft: ModelPickerDraft,
|
||||
step: ModelPickerStep,
|
||||
): Exclude<ModelPickerStep, "model"> | undefined {
|
||||
if (!request.settingsEditable) return undefined;
|
||||
const capabilities = request.capabilitiesFor(draft.model);
|
||||
if (
|
||||
step === "model" &&
|
||||
!request.externalRouting &&
|
||||
request.serviceTier.kind !== "custom" &&
|
||||
(capabilities?.fastMode ||
|
||||
(capabilities === undefined &&
|
||||
draft.model === request.model.current &&
|
||||
request.serviceTier.kind === "priority"))
|
||||
) {
|
||||
return "speed";
|
||||
}
|
||||
if (step !== "reasoning" && reasoningLevels(request, draft).length > 0) {
|
||||
return "reasoning";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function initialModelPickerState(request: ModelSettingsRequest): ModelPickerState {
|
||||
const draft: ModelPickerDraft = {
|
||||
model: request.model.current,
|
||||
speed: request.serviceTier.kind === "priority" ? "priority" : "standard",
|
||||
reasoning: request.reasoning ?? "default",
|
||||
};
|
||||
return {
|
||||
step: "model",
|
||||
draft,
|
||||
select: initialSelectState({
|
||||
options: modelOptions(request),
|
||||
defaultValue: draft.model ?? undefined,
|
||||
}),
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
function selectionDraft(
|
||||
request: ModelSettingsRequest,
|
||||
state: ModelPickerState,
|
||||
value: string,
|
||||
): ModelPickerDraft {
|
||||
const draft = { ...state.draft };
|
||||
switch (state.step) {
|
||||
case "model": {
|
||||
if (request.model.kind === "fixed") return draft;
|
||||
draft.model = value;
|
||||
const capabilities = request.capabilitiesFor(value);
|
||||
if (capabilities !== undefined) {
|
||||
if (!capabilities.fastMode) draft.speed = "standard";
|
||||
if (
|
||||
draft.reasoning !== "default" &&
|
||||
(!capabilities.reasoning || !capabilities.reasoningLevels.includes(draft.reasoning))
|
||||
) {
|
||||
draft.reasoning = "default";
|
||||
}
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
case "speed":
|
||||
draft.speed = SPEED_OPTIONS.find((option) => option.value === value)!.value;
|
||||
return draft;
|
||||
case "reasoning":
|
||||
draft.reasoning = REASONING_OPTIONS.find((option) => option.value === value)!.value;
|
||||
return draft;
|
||||
}
|
||||
}
|
||||
|
||||
function selectionResult(
|
||||
request: ModelSettingsRequest,
|
||||
draft: ModelPickerDraft,
|
||||
): ModelSettingsResult {
|
||||
const result: ModelSettingsResult = {};
|
||||
if (
|
||||
request.model.kind === "pick" &&
|
||||
draft.model !== null &&
|
||||
draft.model !== request.model.current
|
||||
) {
|
||||
result.model = draft.model;
|
||||
}
|
||||
if (request.settingsEditable) {
|
||||
if (draft.reasoning !== (request.reasoning ?? "default")) result.reasoning = draft.reasoning;
|
||||
const originalSpeed = request.serviceTier.kind === "priority" ? "priority" : "standard";
|
||||
if (
|
||||
!request.externalRouting &&
|
||||
request.serviceTier.kind !== "custom" &&
|
||||
draft.speed !== originalSpeed
|
||||
) {
|
||||
result.serviceTier = draft.speed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function transitionModelPicker(
|
||||
state: ModelPickerState,
|
||||
event: ModelPickerEvent,
|
||||
request: ModelSettingsRequest,
|
||||
): ModelPickerTransition {
|
||||
if (event.type === "cancel") return { kind: "cancel" };
|
||||
if (event.type === "back") {
|
||||
const previous = state.history.at(-1);
|
||||
return previous === undefined
|
||||
? { kind: "cancel" }
|
||||
: {
|
||||
kind: "render",
|
||||
state: { ...previous, draft: state.draft, history: state.history.slice(0, -1) },
|
||||
};
|
||||
}
|
||||
const options = stepOptions(request, state.draft, state.step);
|
||||
if (event.type !== "submit") {
|
||||
if (
|
||||
(state.step !== "model" || request.model.kind !== "pick") &&
|
||||
(event.type === "char" || event.type === "backspace")
|
||||
) {
|
||||
return { kind: "render", state };
|
||||
}
|
||||
return {
|
||||
kind: "render",
|
||||
state: { ...state, select: reduceSelect(state.select, event, { options }) },
|
||||
};
|
||||
}
|
||||
const value = selectValueAtCursor(
|
||||
filterOptions(options, state.select.filter),
|
||||
state.select.cursor,
|
||||
);
|
||||
if (value === undefined) return { kind: "render", state };
|
||||
const draft = selectionDraft(request, state, value);
|
||||
const step = nextStep(request, draft, state.step);
|
||||
if (step === undefined) return { kind: "settle", result: selectionResult(request, draft) };
|
||||
return {
|
||||
kind: "render",
|
||||
state: {
|
||||
step,
|
||||
draft,
|
||||
select: initialSelectState({
|
||||
options: stepOptions(request, draft, step),
|
||||
defaultValue: draft[step],
|
||||
}),
|
||||
history: [...state.history, { step: state.step, select: state.select }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function modelPickerTitle(state: ModelPickerState): string {
|
||||
return { model: "Select model", speed: "Select speed", reasoning: "Select reasoning" }[
|
||||
state.step
|
||||
];
|
||||
}
|
||||
|
||||
export function renderModelPicker(
|
||||
request: ModelSettingsRequest,
|
||||
state: ModelPickerState,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
): string[] {
|
||||
const options = stepOptions(request, state.draft, state.step);
|
||||
const value = selectValueAtCursor(
|
||||
filterOptions(options, state.select.filter),
|
||||
state.select.cursor,
|
||||
);
|
||||
const draft = value === undefined ? state.draft : selectionDraft(request, state, value);
|
||||
const last = nextStep(request, draft, state.step) === undefined;
|
||||
const model =
|
||||
modelOptions(request).find((option) => option.value === state.draft.model)?.label ??
|
||||
state.draft.model;
|
||||
const context =
|
||||
state.step === "model"
|
||||
? request.model.kind === "fixed"
|
||||
? request.model.reason
|
||||
: undefined
|
||||
: [
|
||||
model,
|
||||
...(state.step === "reasoning" && state.history.some((page) => page.step === "speed")
|
||||
? [state.draft.speed === "priority" ? "Fast" : "Standard"]
|
||||
: []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return renderSelectQuestion(
|
||||
{
|
||||
kind: state.step === "model" && request.model.kind === "pick" ? "search" : "single",
|
||||
message: "",
|
||||
description: context,
|
||||
options,
|
||||
select: state.select,
|
||||
footerHints: [
|
||||
"↑/↓ move",
|
||||
last ? "Enter apply" : "Enter next",
|
||||
state.history.length > 0 ? "Esc/← back" : "Esc cancel",
|
||||
],
|
||||
},
|
||||
theme,
|
||||
width,
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function setupFlowRenderer() {
|
||||
readSelect: vi.fn(async () => undefined),
|
||||
readEditableSelect: vi.fn(async () => undefined),
|
||||
readProviderPicker: vi.fn(async () => undefined),
|
||||
readModelEditor: vi.fn(async () => undefined),
|
||||
readModelPicker: vi.fn(async () => undefined),
|
||||
readText: vi.fn(async () => undefined),
|
||||
readAcknowledge: vi.fn(async () => {}),
|
||||
readChoice: vi.fn(() => ({ choice: Promise.resolve(undefined), close: vi.fn() })),
|
||||
|
||||
@@ -128,8 +128,9 @@ describe("promptCommandsFor", () => {
|
||||
expect(names).not.toContain("vc:auth");
|
||||
});
|
||||
|
||||
it("exposes the Vercel CLI commands for remote sessions", () => {
|
||||
it("leads remote sessions with help and hides local setup commands", () => {
|
||||
const names = promptCommandsFor("remote").map((command) => command.name);
|
||||
expect(names[0]).toBe("help");
|
||||
expect(names).not.toContain("vc:install");
|
||||
expect(names).not.toContain("vc:login");
|
||||
expect(names).not.toContain("vc:auth");
|
||||
@@ -189,8 +190,8 @@ describe("PROMPT_COMMANDS registry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("leads with /help so a bare slash defaults to the safest command", () => {
|
||||
expect(PROMPT_COMMANDS[0]?.name).toBe("help");
|
||||
it("leads with /model so a bare slash opens model selection", () => {
|
||||
expect(PROMPT_COMMANDS[0]?.name).toBe("model");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,22 +44,13 @@ interface PromptCommandDefinition extends PromptCommandSpec {
|
||||
* transcript-echo suppression, and command discovery cannot drift apart.
|
||||
*/
|
||||
const PROMPT_COMMAND_DEFINITIONS = [
|
||||
// `help` leads so that the typeahead's default highlight — what a bare `/`
|
||||
// plus Enter submits — is the safest command, not session-resetting `/reset`.
|
||||
{
|
||||
name: "help",
|
||||
name: "model",
|
||||
aliases: [],
|
||||
description: "Show available commands",
|
||||
takesArgument: false,
|
||||
build: () => ({ type: "help" }),
|
||||
targets: ["local", "remote"],
|
||||
},
|
||||
{
|
||||
name: "info",
|
||||
aliases: [],
|
||||
description: "Show application and messaging information",
|
||||
takesArgument: false,
|
||||
build: () => ({ type: "info" }),
|
||||
description: "Choose a model, speed, and reasoning",
|
||||
argumentHint: "[provider/model]",
|
||||
takesArgument: true,
|
||||
build: (argument) => ({ type: "extension", name: "model", argument }),
|
||||
targets: ["local"],
|
||||
},
|
||||
{
|
||||
@@ -70,14 +61,6 @@ const PROMPT_COMMAND_DEFINITIONS = [
|
||||
build: () => ({ type: "reset" }),
|
||||
targets: ["local", "remote"],
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
aliases: [],
|
||||
description: "Cancel the running turn",
|
||||
takesArgument: false,
|
||||
build: () => ({ type: "cancel" }),
|
||||
targets: ["local", "remote"],
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
aliases: ["new"],
|
||||
@@ -94,6 +77,14 @@ const PROMPT_COMMAND_DEFINITIONS = [
|
||||
build: () => ({ type: "compact" }),
|
||||
targets: ["local", "remote"],
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
aliases: [],
|
||||
description: "Cancel the running turn",
|
||||
takesArgument: false,
|
||||
build: () => ({ type: "cancel" }),
|
||||
targets: ["local", "remote"],
|
||||
},
|
||||
{
|
||||
name: "login",
|
||||
aliases: [],
|
||||
@@ -102,33 +93,6 @@ const PROMPT_COMMAND_DEFINITIONS = [
|
||||
build: () => ({ type: "extension", name: "login", argument: "" }),
|
||||
targets: ["local"],
|
||||
},
|
||||
{
|
||||
name: "model",
|
||||
aliases: [],
|
||||
description: "Choose a model and its settings",
|
||||
argumentHint: "[provider/model]",
|
||||
takesArgument: true,
|
||||
build: (argument) => ({ type: "extension", name: "model", argument }),
|
||||
targets: ["local"],
|
||||
},
|
||||
{
|
||||
name: "loglevel",
|
||||
aliases: [],
|
||||
description: "Show or hide captured stdout/stderr/sandbox logs",
|
||||
argumentHint: "[all|stderr|sandbox|none]",
|
||||
takesArgument: true,
|
||||
build: (argument) => ({ type: "loglevel", argument }),
|
||||
targets: ["local", "remote"],
|
||||
},
|
||||
{
|
||||
name: "traces",
|
||||
aliases: [],
|
||||
description: "Open the local trace viewer",
|
||||
argumentHint: "[trace]",
|
||||
takesArgument: true,
|
||||
build: (argument) => ({ type: "traces", argument }),
|
||||
targets: ["local"],
|
||||
},
|
||||
{
|
||||
name: "add",
|
||||
aliases: [],
|
||||
@@ -145,6 +109,40 @@ const PROMPT_COMMAND_DEFINITIONS = [
|
||||
build: () => ({ type: "extension", name: "deploy", argument: "" }),
|
||||
targets: ["local"],
|
||||
},
|
||||
{
|
||||
name: "traces",
|
||||
aliases: [],
|
||||
description: "Open the local trace viewer",
|
||||
argumentHint: "[trace]",
|
||||
takesArgument: true,
|
||||
build: (argument) => ({ type: "traces", argument }),
|
||||
targets: ["local"],
|
||||
},
|
||||
{
|
||||
name: "loglevel",
|
||||
aliases: [],
|
||||
description: "Show or hide captured stdout/stderr/sandbox logs",
|
||||
argumentHint: "[all|stderr|sandbox|none]",
|
||||
takesArgument: true,
|
||||
build: (argument) => ({ type: "loglevel", argument }),
|
||||
targets: ["local", "remote"],
|
||||
},
|
||||
{
|
||||
name: "info",
|
||||
aliases: [],
|
||||
description: "Show application and messaging information",
|
||||
takesArgument: false,
|
||||
build: () => ({ type: "info" }),
|
||||
targets: ["local"],
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
aliases: [],
|
||||
description: "Show available commands",
|
||||
takesArgument: false,
|
||||
build: () => ({ type: "help" }),
|
||||
targets: ["local", "remote"],
|
||||
},
|
||||
{
|
||||
name: "exit",
|
||||
aliases: ["quit"],
|
||||
@@ -158,9 +156,17 @@ const PROMPT_COMMAND_DEFINITIONS = [
|
||||
export const PROMPT_COMMANDS: readonly PromptCommandSpec[] = PROMPT_COMMAND_DEFINITIONS;
|
||||
|
||||
export function promptCommandsFor(target: PromptCommandTarget): readonly PromptCommandSpec[] {
|
||||
return PROMPT_COMMAND_DEFINITIONS.filter((definition) =>
|
||||
const commands = PROMPT_COMMAND_DEFINITIONS.filter((definition) =>
|
||||
definition.targets.some((supportedTarget) => supportedTarget === target),
|
||||
);
|
||||
// Remote sessions have no model picker, so keep bare `/` from defaulting to reset.
|
||||
if (target === "remote") {
|
||||
return [
|
||||
...commands.filter((command) => command.name === "help"),
|
||||
...commands.filter((command) => command.name !== "help"),
|
||||
];
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
/** Whether a command runs against this target — the one authority dispatch shares with discovery. */
|
||||
|
||||
@@ -562,7 +562,7 @@ function idleSetupFlow(): SetupFlowRenderer {
|
||||
readSelect: vi.fn(async () => undefined),
|
||||
readEditableSelect: vi.fn(async () => undefined),
|
||||
readProviderPicker: vi.fn(async () => undefined),
|
||||
readModelEditor: vi.fn(async () => undefined),
|
||||
readModelPicker: vi.fn(async () => undefined),
|
||||
readText: vi.fn(async () => undefined),
|
||||
readAcknowledge: vi.fn(async () => {}),
|
||||
readChoice: vi.fn(() => ({ choice: Promise.resolve(undefined), close: vi.fn() })),
|
||||
|
||||
@@ -26,7 +26,7 @@ function fakePanelRenderer(): TuiSetupCommandRenderer & {
|
||||
readSelect: vi.fn(async () => []),
|
||||
readEditableSelect: vi.fn(async () => undefined),
|
||||
readProviderPicker: vi.fn(async () => undefined),
|
||||
readModelEditor: vi.fn(async () => undefined),
|
||||
readModelPicker: vi.fn(async () => undefined),
|
||||
readText: vi.fn(async () => ""),
|
||||
readAcknowledge: vi.fn(async () => {}),
|
||||
readChoice: vi.fn(() => ({ choice: Promise.resolve(undefined), close: vi.fn() })),
|
||||
|
||||
@@ -40,11 +40,11 @@ export const SETUP_FLOW_CONFIG = {
|
||||
export type TuiSetupCommandRenderer = TuiPrompterRenderer &
|
||||
Pick<
|
||||
SetupFlowRenderer,
|
||||
"readProviderPicker" | "readModelEditor" | "setNavigation" | "waitForInterrupt"
|
||||
"readProviderPicker" | "readModelPicker" | "setNavigation" | "waitForInterrupt"
|
||||
>;
|
||||
|
||||
type MuteableSetupRenderer = TuiPrompterRenderer &
|
||||
Pick<SetupFlowRenderer, "readProviderPicker" | "readModelEditor" | "setNavigation">;
|
||||
Pick<SetupFlowRenderer, "readProviderPicker" | "readModelPicker" | "setNavigation">;
|
||||
|
||||
export type OnboardingScreenEvent = {
|
||||
screen:
|
||||
@@ -119,8 +119,8 @@ function muteableRenderer(
|
||||
isMuted() ? Promise.resolve(undefined) : renderer.readEditableSelect(options),
|
||||
readProviderPicker: (options) =>
|
||||
isMuted() ? Promise.resolve(undefined) : renderer.readProviderPicker(options),
|
||||
readModelEditor: (options) =>
|
||||
isMuted() ? Promise.resolve(undefined) : renderer.readModelEditor(options),
|
||||
readModelPicker: (options) =>
|
||||
isMuted() ? Promise.resolve(undefined) : renderer.readModelPicker(options),
|
||||
readText: (options) => (isMuted() ? Promise.resolve(undefined) : renderer.readText(options)),
|
||||
readAcknowledge: (options) =>
|
||||
isMuted() ? Promise.resolve() : renderer.readAcknowledge(options),
|
||||
@@ -286,7 +286,7 @@ async function executeSetupCommand(
|
||||
signal,
|
||||
chatGptAccountLabel: input.chatGptAccountLabel,
|
||||
deps: {
|
||||
pickModelSettings: (request) => renderer.readModelEditor(request),
|
||||
pickModelSettings: (request) => renderer.readModelPicker(request),
|
||||
},
|
||||
};
|
||||
if (input.initialModelStep !== undefined) {
|
||||
|
||||
@@ -93,8 +93,8 @@ export interface SetupFlowRenderer {
|
||||
}): Promise<SetupEditableSelectResult | undefined>;
|
||||
/** Provider-only picker with masked async validation. Not part of Prompter. */
|
||||
readProviderPicker(options: ProviderPickerRequest): Promise<ProviderPickerChoice | undefined>;
|
||||
/** Composite Change-model screen: catalog list, reasoning slider, tier toggle. Not part of Prompter. */
|
||||
readModelEditor(options: ModelSettingsRequest): Promise<ModelSettingsResult | undefined>;
|
||||
/** Model, speed, and reasoning picker. Not part of Prompter. */
|
||||
readModelPicker(options: ModelSettingsRequest): Promise<ModelSettingsResult | undefined>;
|
||||
readText(options: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { lineOf } from "./line-editor.js";
|
||||
import {
|
||||
renderAcknowledgeQuestion,
|
||||
renderFlowPanel,
|
||||
renderModelEditorQuestion,
|
||||
renderSelectQuestion,
|
||||
renderTextQuestion,
|
||||
} from "./setup-panel.js";
|
||||
@@ -933,141 +932,6 @@ describe("renderAcknowledgeQuestion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderModelEditorQuestion", () => {
|
||||
// Editor requests carry id-labeled rows (the flow's modelListRows mapping).
|
||||
const MODEL_IDS = [
|
||||
"anthropic/claude-sonnet-5",
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.6-luna",
|
||||
"xai/grok-4.5",
|
||||
"google/gemini-3.5",
|
||||
"zai/glm-4.6",
|
||||
"meta/llama-5",
|
||||
"mistral/large-3",
|
||||
"cohere/command-b",
|
||||
];
|
||||
const MODELS = MODEL_IDS.map((id, index) =>
|
||||
index < 2 ? { value: id, label: id, featured: true } : { value: id, label: id },
|
||||
);
|
||||
|
||||
const CAPS = {
|
||||
reasoning: true,
|
||||
reasoningLevels: ["low", "medium", "high"],
|
||||
fastMode: true,
|
||||
} as const;
|
||||
|
||||
function editorRequest(overrides = {}) {
|
||||
return {
|
||||
model: { kind: "pick", options: MODELS, current: "anthropic/claude-sonnet-5" },
|
||||
reasoning: null,
|
||||
serviceTier: { kind: "standard" },
|
||||
settingsEditable: true,
|
||||
externalRouting: false,
|
||||
capabilitiesFor: () => CAPS,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
function editorState(overrides = {}) {
|
||||
return {
|
||||
screen: { kind: "menu", cursor: "model" },
|
||||
draft: { modelId: "anthropic/claude-sonnet-5", reasoning: "medium", tier: "standard" },
|
||||
capabilities: CAPS,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
it("summarizes a fast-mode draft and an unset level on the menu hints", () => {
|
||||
const text = renderModelEditorQuestion(
|
||||
{
|
||||
request: editorRequest(),
|
||||
state: editorState({
|
||||
draft: { modelId: "anthropic/claude-sonnet-5", reasoning: "default", tier: "priority" },
|
||||
}),
|
||||
},
|
||||
theme,
|
||||
80,
|
||||
).join("\n");
|
||||
|
||||
expect(text).toContain("○─○─○ provider default");
|
||||
expect(text).toContain("fast ↯");
|
||||
});
|
||||
|
||||
it("paints the hovered row's covered track stretch blue", () => {
|
||||
const text = renderModelEditorQuestion(
|
||||
{
|
||||
request: editorRequest(),
|
||||
state: editorState({ screen: { kind: "menu", cursor: "reasoning" } }),
|
||||
},
|
||||
colorTheme,
|
||||
80,
|
||||
).join("\n");
|
||||
|
||||
expect(text).toContain(`${colorTheme.colors.blue("●─◉")}─○`);
|
||||
});
|
||||
|
||||
it("disables the reasoning row with its reason and omits the tier for a no-frills model", () => {
|
||||
const noFrills = { reasoning: false, reasoningLevels: [], fastMode: false };
|
||||
const text = renderModelEditorQuestion(
|
||||
{
|
||||
request: editorRequest(),
|
||||
state: editorState({
|
||||
capabilities: noFrills,
|
||||
draft: { modelId: "test/no-frills", reasoning: "default", tier: "standard" },
|
||||
}),
|
||||
},
|
||||
theme,
|
||||
80,
|
||||
).join("\n");
|
||||
|
||||
expect(text).toContain("Not supported by the selected model");
|
||||
expect(text).not.toContain("Service tier");
|
||||
});
|
||||
|
||||
it("lists model ids with a bold selection and enter badge", () => {
|
||||
const rows = renderModelEditorQuestion(
|
||||
{
|
||||
request: editorRequest(),
|
||||
state: editorState({
|
||||
screen: {
|
||||
kind: "model",
|
||||
select: initialSelectState({ options: MODELS, defaultValue: "openai/gpt-5.6-sol" }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
theme,
|
||||
80,
|
||||
);
|
||||
const text = rows.join("\n");
|
||||
|
||||
expect(text).toContain("Select the model");
|
||||
expect(text).toContain(" type to search");
|
||||
expect(text).toContain(" › openai/gpt-5.6-sol ");
|
||||
expect(text).toContain("↵");
|
||||
expect(text).toContain(" anthropic/claude-sonnet-5");
|
||||
// Five rows in view, no count row, Esc-only footer.
|
||||
expect(text).toContain("openai/gpt-5.6-luna");
|
||||
expect(text).not.toContain("google/gemini-3.5");
|
||||
expect(text).not.toContain("options, showing");
|
||||
expect(text).toContain("Esc back");
|
||||
});
|
||||
|
||||
it("falls back to ASCII track glyphs without unicode", () => {
|
||||
const ascii = createTheme({ color: false, unicode: false });
|
||||
const text = renderModelEditorQuestion(
|
||||
{
|
||||
request: editorRequest(),
|
||||
state: editorState({ screen: { kind: "menu", cursor: "reasoning" } }),
|
||||
},
|
||||
ascii,
|
||||
80,
|
||||
).join("\n");
|
||||
|
||||
expect(text).toContain("*-O-. medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compact menus", () => {
|
||||
it.each([32, 80])("keeps options on single borderless rows at %i columns", (columns) => {
|
||||
const options = [
|
||||
|
||||
@@ -9,14 +9,7 @@ import {
|
||||
type SelectState,
|
||||
} from "#setup/cli/select-state.js";
|
||||
import type { PlannerNavigation, SelectMetadata, SelectNotice } from "#setup/prompter.js";
|
||||
import type { ModelSettingsRequest } from "#setup/flows/model.js";
|
||||
|
||||
import {
|
||||
modelEditorMenuRows,
|
||||
reasoningPositions,
|
||||
type ModelEditorRowId,
|
||||
type ModelEditorState,
|
||||
} from "./model-editor.js";
|
||||
import type { ProviderPickerPhase } from "./provider-picker.js";
|
||||
import { maskLine, visibleLine, type LineState } from "./line-editor.js";
|
||||
import type { Theme } from "./theme.js";
|
||||
@@ -56,6 +49,7 @@ interface SetupSelectPanelBase extends SetupQuestionPanelBase {
|
||||
loadingFrame?: string;
|
||||
/** A dim-inverse affordance appended to the cursor row, e.g. ` ↵ change `. */
|
||||
cursorBadge?: string;
|
||||
footerHints?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -921,9 +915,10 @@ export function renderSelectQuestion(
|
||||
|
||||
rows.push(
|
||||
...questionFooter(
|
||||
railed
|
||||
? ["Enter select", "Esc back"]
|
||||
: selectFooterHints(presentation, visible, cursor, plannerNavigation),
|
||||
state.footerHints ??
|
||||
(railed
|
||||
? ["Enter select", "Esc back"]
|
||||
: selectFooterHints(presentation, visible, cursor, plannerNavigation)),
|
||||
theme,
|
||||
width,
|
||||
),
|
||||
@@ -931,14 +926,6 @@ export function renderSelectQuestion(
|
||||
return rows.map((row) => clip(row, width));
|
||||
}
|
||||
|
||||
/** The composite Change-model screen's inputs: the resolved request plus live state. */
|
||||
export interface ModelEditorPanelInput {
|
||||
request: ModelSettingsRequest;
|
||||
state: ModelEditorState;
|
||||
}
|
||||
|
||||
const MODEL_EDITOR_MESSAGE = "Select the model";
|
||||
|
||||
/**
|
||||
* A dim, background-free selection badge carrying the Enter affordance, e.g.
|
||||
* `↵`, `↵ change`, `↵ validate`.
|
||||
@@ -948,141 +935,6 @@ export function enterBadge(theme: Theme, label?: string): string {
|
||||
return c.dim(label === undefined ? theme.glyph.enter : `${theme.glyph.enter} ${label}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A discrete reasoning track: `●` below the current notch, `◉` on it, `○`
|
||||
* above, joined by `─` connectors. `accent` paints the covered stretch —
|
||||
* notches and connectors up to the current position — blue. `index` -1 means
|
||||
* unset: an all-`○` track with no fill.
|
||||
*/
|
||||
export function reasoningTrack(input: {
|
||||
count: number;
|
||||
index: number;
|
||||
connectorWidth: number;
|
||||
accent: boolean;
|
||||
theme: Theme;
|
||||
}): string {
|
||||
const { count, index, theme } = input;
|
||||
const glyphs = theme.glyph;
|
||||
const connector = glyphs.trackLine.repeat(input.connectorWidth);
|
||||
const pieces: string[] = [];
|
||||
for (let at = 0; at < count; at += 1) {
|
||||
if (at > 0) pieces.push(connector);
|
||||
pieces.push(
|
||||
at < index ? glyphs.trackFilled : at === index ? glyphs.trackCurrent : glyphs.trackEmpty,
|
||||
);
|
||||
}
|
||||
if (!input.accent || index < 0) return pieces.join("");
|
||||
// Pieces alternate notch/connector; the covered stretch ends at the current
|
||||
// notch, which sits at piece position 2 * index.
|
||||
const covered = pieces.slice(0, 2 * index + 1).join("");
|
||||
return `${theme.colors.blue(covered)}${pieces.slice(2 * index + 1).join("")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the Change-model screen: a value menu whose reasoning and tier rows
|
||||
* adjust inline with left/right, and whose Model row opens the searchable
|
||||
* catalog.
|
||||
*/
|
||||
export function renderModelEditorQuestion(
|
||||
input: ModelEditorPanelInput,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
message = MODEL_EDITOR_MESSAGE,
|
||||
): string[] {
|
||||
const { request, state } = input;
|
||||
switch (state.screen.kind) {
|
||||
case "menu":
|
||||
return renderModelEditorMenu(input, state.screen.cursor, theme, width);
|
||||
case "model":
|
||||
return renderModelEditorModelScreen(request, state.screen.select, theme, width, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The value menu rides the ordinary stacked-select painter: each row's hint
|
||||
* line is the drafted value (mini track, `fast ↯`, the slug), dim at rest and
|
||||
* accent-keeping under the cursor.
|
||||
*/
|
||||
function renderModelEditorMenu(
|
||||
input: ModelEditorPanelInput,
|
||||
cursor: ModelEditorRowId,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
): string[] {
|
||||
const { request, state } = input;
|
||||
const { draft } = state;
|
||||
const rows = modelEditorMenuRows(request, draft, state.capabilities);
|
||||
|
||||
const options: SetupPanelOption[] = rows.map((row) => {
|
||||
if (row.disabled === true && row.value === "model") {
|
||||
// The fixed model still shows its id; the description carries the reason.
|
||||
return draft.modelId === null ? { ...row } : { ...row, hint: draft.modelId };
|
||||
}
|
||||
if (row.disabled === true || row.value === "done") return { ...row };
|
||||
switch (row.value) {
|
||||
case "model":
|
||||
return { ...row, hint: draft.modelId ?? undefined };
|
||||
case "reasoning": {
|
||||
const positions = reasoningPositions(state.capabilities, draft.reasoning);
|
||||
const index = draft.reasoning === "default" ? -1 : positions.indexOf(draft.reasoning);
|
||||
const track = reasoningTrack({
|
||||
count: positions.length,
|
||||
index,
|
||||
connectorWidth: 1,
|
||||
accent: row.value === cursor,
|
||||
theme,
|
||||
});
|
||||
const level = draft.reasoning === "default" ? "provider default" : draft.reasoning;
|
||||
// Track first: the notches hold a fixed column while the
|
||||
// variable-width level name trails, so nothing jumps on adjust.
|
||||
return { ...row, hint: `${track} ${level}` };
|
||||
}
|
||||
case "tier":
|
||||
return { ...row, hint: draft.tier === "priority" ? `fast ${theme.glyph.fast}` : "normal" };
|
||||
default:
|
||||
return { ...row };
|
||||
}
|
||||
});
|
||||
|
||||
const index = Math.max(
|
||||
0,
|
||||
options.findIndex((option) => option.value === cursor),
|
||||
);
|
||||
return renderSelectQuestion(
|
||||
{
|
||||
kind: "stacked",
|
||||
message: "",
|
||||
options,
|
||||
select: { filter: "", cursor: index, selected: new Set() },
|
||||
},
|
||||
theme,
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
/** The catalog sub-screen: `▏`-railed id rows under a `▏` filter line. */
|
||||
function renderModelEditorModelScreen(
|
||||
request: ModelSettingsRequest,
|
||||
select: SelectState,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
message: string,
|
||||
): string[] {
|
||||
// The catalog list IS the shared railed searchable select — the same
|
||||
// component behind the team and project pickers.
|
||||
return renderSelectQuestion(
|
||||
{
|
||||
kind: "search",
|
||||
message,
|
||||
options: request.model.kind === "pick" ? request.model.options : [],
|
||||
placeholder: "type to search",
|
||||
select,
|
||||
},
|
||||
theme,
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
/** Paints a text question section: message, a block-cursor input line, hints. */
|
||||
export function renderTextQuestion(
|
||||
state: SetupTextPanelState,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
formatCompactTokenCount,
|
||||
formatTokenFlow,
|
||||
formatTurnDuration,
|
||||
typewriterText,
|
||||
isIncompletePaste,
|
||||
nextKey,
|
||||
parseKey,
|
||||
@@ -75,16 +74,6 @@ describe("formatTokenFlow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("typewriterText", () => {
|
||||
it("reveals one character per step, then holds the full text", () => {
|
||||
expect(typewriterText("Working…", 0, 80)).toBe("W");
|
||||
expect(typewriterText("Working…", 79, 80)).toBe("W");
|
||||
expect(typewriterText("Working…", 160, 80)).toBe("Wor");
|
||||
expect(typewriterText("Working…", 560, 80)).toBe("Working…");
|
||||
expect(typewriterText("Working…", 60_000, 80)).toBe("Working…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTurnDuration", () => {
|
||||
it("scales through seconds, minutes, and hours", () => {
|
||||
expect(formatTurnDuration(400)).toBe("1s");
|
||||
|
||||
@@ -361,17 +361,6 @@ export function formatCompactTokenCount(count: number): string {
|
||||
return `${scaled.toFixed(1).replace(/\.0$/, "")}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveals `text` one character per `stepMs` of elapsed time, typewriter
|
||||
* style: the first character shows immediately, the full text after
|
||||
* `(length - 1) * stepMs`. Painted on the shared ticker beat, so no timer
|
||||
* of its own.
|
||||
*/
|
||||
export function typewriterText(text: string, elapsedMs: number, stepMs: number): string {
|
||||
const visible = Math.floor(Math.max(0, elapsedMs) / stepMs) + 1;
|
||||
return visible >= text.length ? text : text.slice(0, visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a turn's wall-clock duration for the end-of-turn stats line:
|
||||
* `42s`, `3min 24s`, `1h 12min`. Sub-second turns round up to `1s` — a
|
||||
|
||||
@@ -612,7 +612,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(screen.snapshot()).toContain("W 1s");
|
||||
expect(screen.snapshot()).toContain("Thinking (0s)");
|
||||
expect(screen.snapshot()).not.toContain("Ctrl+C to interrupt");
|
||||
|
||||
streamController?.close();
|
||||
@@ -637,7 +637,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
|
||||
await Promise.resolve();
|
||||
// The bar renders; connection state lives in its own section.
|
||||
expect(screen.snapshot()).toContain("W 1s");
|
||||
expect(screen.snapshot()).toContain("Thinking (0s)");
|
||||
|
||||
streamController?.close();
|
||||
await rendering;
|
||||
@@ -667,8 +667,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
|
||||
await Promise.resolve();
|
||||
let lines = screen.snapshot().split("\n");
|
||||
// The label types itself out: one character at t=0.
|
||||
let barRow = lines.findIndex((line) => line === "▪ W 1s");
|
||||
let barRow = lines.findIndex((line) => line === "• Thinking (0s)");
|
||||
expect(barRow).toBeGreaterThan(-1);
|
||||
// The pending prompt row wears the same default-color `❯` as the idle one
|
||||
// beneath the bar; the status line follows it.
|
||||
@@ -676,11 +675,10 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
expect(lines[barRow + 2]).toContain("❯");
|
||||
expect(lines[barRow + 4]).toContain("gpt-5");
|
||||
|
||||
// The duration ticks live while the pulse blinks on the shared beat.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
// Advance past the second boundary so the shared paint ticker catches it.
|
||||
await vi.advanceTimersByTimeAsync(2_100);
|
||||
lines = screen.snapshot().split("\n");
|
||||
// Fully revealed once the reveal window has passed.
|
||||
barRow = lines.findIndex((line) => line.includes("Working for 2s"));
|
||||
barRow = lines.findIndex((line) => line.includes("Thinking (2s)"));
|
||||
expect(barRow).toBeGreaterThan(-1);
|
||||
expect(lines[barRow + 2]).toContain("❯");
|
||||
expect(lines[barRow + 4]).toContain("gpt-5");
|
||||
@@ -708,7 +706,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
input.enter();
|
||||
|
||||
expect(await prompt).toBe("hello");
|
||||
expect(screen.snapshot()).toContain("* W 1s");
|
||||
expect(screen.snapshot()).toContain("* Thinking (0s)");
|
||||
expect(screen.snapshot()).not.toContain("⊙");
|
||||
renderer.shutdown();
|
||||
});
|
||||
@@ -1880,7 +1878,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
expect(screen.snapshot()).toContain("❯ hello");
|
||||
expect(screen.snapshot()).not.toContain("Send a message…");
|
||||
input.enter();
|
||||
expect(screen.snapshot()).toContain("W 1s");
|
||||
expect(screen.snapshot()).toContain("Thinking (0s)");
|
||||
expect(await prompt).toBe("hello");
|
||||
renderer.shutdown();
|
||||
});
|
||||
@@ -1895,11 +1893,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
input.enter();
|
||||
|
||||
expect(await prompt).toBe("hello");
|
||||
expect(screen.snapshot()).toContain("▪ W 1s");
|
||||
|
||||
// The typewriter label advances while the submit wait ticks.
|
||||
vi.advanceTimersByTime(450);
|
||||
expect(screen.snapshot()).toContain("Workin");
|
||||
expect(screen.snapshot()).toContain("• Thinking (0s)");
|
||||
|
||||
let streamController: ReadableStreamDefaultController<AgentTUIStreamEvent> | undefined;
|
||||
const rendering = renderer.renderStream(
|
||||
@@ -1915,7 +1909,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
await Promise.resolve();
|
||||
// The stream keeps the same bar — one working indicator end to end.
|
||||
expect(screen.snapshot()).not.toContain("⊙");
|
||||
expect(screen.snapshot()).toContain(" 1s");
|
||||
expect(screen.snapshot()).toContain("Thinking (0s)");
|
||||
|
||||
streamController?.close();
|
||||
await rendering;
|
||||
@@ -2034,7 +2028,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.snapshot()).toContain(" 1s");
|
||||
expect(screen.snapshot()).toContain("Thinking (0s)");
|
||||
});
|
||||
streamController?.enqueue({
|
||||
type: "reasoning-delta",
|
||||
@@ -2044,7 +2038,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
await Promise.resolve();
|
||||
// The trace never reaches the screen; the bar carries the turn.
|
||||
const lines = screen.snapshot().split("\n");
|
||||
const barRow = lines.findIndex((line) => line.includes(" 1s"));
|
||||
const barRow = lines.findIndex((line) => line.includes("Thinking (0s)"));
|
||||
expect(barRow).toBeGreaterThan(-1);
|
||||
expect(screen.snapshot()).not.toContain("the plan is to check the forecast");
|
||||
// The pending prompt row holds its place below, then the status line.
|
||||
@@ -2402,7 +2396,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
{ submittedPrompt: "another task", continueSession: true },
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.snapshot()).toContain("Working for");
|
||||
expect(screen.snapshot()).toContain("Thinking (");
|
||||
});
|
||||
input.type("go south");
|
||||
input.enter();
|
||||
@@ -2800,7 +2794,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
input.type("no");
|
||||
input.enter();
|
||||
await answer;
|
||||
expect(screen.snapshot()).toContain("W 1s");
|
||||
expect(screen.snapshot()).toContain("Thinking (0s)");
|
||||
renderer.shutdown();
|
||||
});
|
||||
|
||||
@@ -3975,7 +3969,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
});
|
||||
input.type("n");
|
||||
expect(await approval).toEqual({ approved: false, reason: "Denied by user." });
|
||||
expect(screen.snapshot()).toContain("W 1s");
|
||||
expect(screen.snapshot()).toContain("Thinking (0s)");
|
||||
renderer.shutdown();
|
||||
|
||||
const snapshot = screen.snapshot();
|
||||
@@ -4483,85 +4477,6 @@ describe("TerminalRenderer setup panel", () => {
|
||||
renderer.shutdown();
|
||||
});
|
||||
|
||||
it("walks the model editor from pick to slider to toggle to Done", async () => {
|
||||
const { screen, input, renderer } = makeRenderer(100, 40);
|
||||
renderer.setupFlow.begin("Configure the agent model");
|
||||
const answer = renderer.setupFlow.readModelEditor({
|
||||
model: {
|
||||
kind: "pick",
|
||||
options: [
|
||||
{
|
||||
value: "anthropic/claude-sonnet-5",
|
||||
label: "anthropic/claude-sonnet-5",
|
||||
featured: true,
|
||||
},
|
||||
{ value: "xai/grok-4.5", label: "xai/grok-4.5" },
|
||||
],
|
||||
current: "anthropic/claude-sonnet-5",
|
||||
},
|
||||
reasoning: null,
|
||||
serviceTier: { kind: "standard" },
|
||||
settingsEditable: true,
|
||||
externalRouting: false,
|
||||
capabilitiesFor: () => ({
|
||||
reasoning: true,
|
||||
reasoningLevels: ["low", "high"],
|
||||
fastMode: true,
|
||||
}),
|
||||
});
|
||||
|
||||
// The value menu opens on the Model row.
|
||||
expect(screen.snapshot()).toContain("› Model");
|
||||
input.enter();
|
||||
expect(screen.snapshot()).toContain("Select the model");
|
||||
input.type("grok");
|
||||
expect(screen.snapshot()).toContain("xai/grok-4.5");
|
||||
input.enter();
|
||||
await expect(answer).resolves.toEqual({ model: "xai/grok-4.5" });
|
||||
renderer.setupFlow.end({ preserveDiagnostics: false });
|
||||
renderer.shutdown();
|
||||
});
|
||||
|
||||
it("unwinds Esc through filter, sub-screen, and menu before cancelling", async () => {
|
||||
const { screen, input, renderer } = makeRenderer(100, 40);
|
||||
renderer.setupFlow.begin("Configure the agent model");
|
||||
const answer = renderer.setupFlow.readModelEditor({
|
||||
model: {
|
||||
kind: "pick",
|
||||
options: [{ value: "anthropic/claude-sonnet-5", label: "Claude Sonnet 5" }],
|
||||
current: "anthropic/claude-sonnet-5",
|
||||
},
|
||||
reasoning: null,
|
||||
serviceTier: { kind: "standard" },
|
||||
settingsEditable: true,
|
||||
externalRouting: false,
|
||||
capabilitiesFor: () => undefined,
|
||||
});
|
||||
let settled = false;
|
||||
void answer.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
input.enter();
|
||||
input.type("sonnet");
|
||||
input.send("\x1b");
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(settled).toBe(false);
|
||||
// The first Esc only cleared the filter; the list is still open.
|
||||
expect(screen.snapshot()).toContain("type to search");
|
||||
|
||||
input.send("\x1b");
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(settled).toBe(false);
|
||||
// Back on the menu.
|
||||
expect(screen.snapshot()).toContain("› Model");
|
||||
|
||||
input.send("\x1b");
|
||||
await expect(answer).resolves.toBeUndefined();
|
||||
renderer.setupFlow.end({ preserveDiagnostics: false });
|
||||
renderer.shutdown();
|
||||
});
|
||||
|
||||
it("drives the ephemeral flow status through the footer", () => {
|
||||
const { screen, renderer } = makeRenderer();
|
||||
|
||||
@@ -5290,14 +5205,14 @@ describe("TerminalRenderer command typeahead", () => {
|
||||
const snapshot = screen.snapshot();
|
||||
expect(snapshot).toContain("/help");
|
||||
expect(snapshot).toContain("Show available commands");
|
||||
expect(snapshot).toContain("Choose a model and its settings");
|
||||
expect(snapshot).toContain("Choose a model, speed, and reasoning");
|
||||
const promptLine = snapshot.split("\n").find((line) => line.includes("❯ /"));
|
||||
expect(promptLine?.startsWith("❯ /")).toBe(true);
|
||||
|
||||
input.enter();
|
||||
// The highlighted default — /help leads the registry — is what a bare
|
||||
// The highlighted default — /model leads the registry — is what a bare
|
||||
// slash submits.
|
||||
expect(await prompt).toBe("/help");
|
||||
expect(await prompt).toBe("/model");
|
||||
renderer.shutdown();
|
||||
});
|
||||
|
||||
@@ -5312,7 +5227,7 @@ describe("TerminalRenderer command typeahead", () => {
|
||||
expect(snapshot).toContain("/model");
|
||||
expect(snapshot).toContain("[provider/model]");
|
||||
// ...and the dropdown (with its description column) is gone.
|
||||
expect(snapshot).not.toContain("Choose a model and its settings");
|
||||
expect(snapshot).not.toContain("Choose a model, speed, and reasoning");
|
||||
|
||||
input.enter();
|
||||
expect(await prompt).toBe("/model");
|
||||
@@ -5369,9 +5284,9 @@ describe("TerminalRenderer command typeahead", () => {
|
||||
input.type("/");
|
||||
input.down();
|
||||
input.enter();
|
||||
// Down moved /help → /info; history recall would have submitted the
|
||||
// Down moved /model → /reset; history recall would have submitted the
|
||||
// earlier prompt instead.
|
||||
expect(await second).toBe("/info");
|
||||
expect(await second).toBe("/reset");
|
||||
renderer.shutdown();
|
||||
});
|
||||
|
||||
@@ -5389,7 +5304,7 @@ describe("TerminalRenderer command typeahead", () => {
|
||||
});
|
||||
|
||||
input.type("m");
|
||||
expect(screen.snapshot()).toContain("Choose a model and its settings");
|
||||
expect(screen.snapshot()).toContain("Choose a model, speed, and reasoning");
|
||||
input.enter();
|
||||
expect(await prompt).toBe("/model");
|
||||
renderer.shutdown();
|
||||
@@ -5425,7 +5340,7 @@ describe("TerminalRenderer command typeahead", () => {
|
||||
input.type("/");
|
||||
const snapshot = screen.snapshot();
|
||||
expect(snapshot).not.toContain("Authenticate with Vercel");
|
||||
expect(snapshot).not.toContain("Choose a model and its settings");
|
||||
expect(snapshot).not.toContain("Choose a model, speed, and reasoning");
|
||||
input.enter();
|
||||
await prompt;
|
||||
renderer.shutdown();
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
renderFlowPanel,
|
||||
flowMessageRows,
|
||||
renderAcknowledgeQuestion,
|
||||
renderModelEditorQuestion,
|
||||
renderSelectQuestion,
|
||||
renderTextQuestion,
|
||||
type FlowPanelContent,
|
||||
@@ -51,10 +50,12 @@ import {
|
||||
type SetupSelectPanelState,
|
||||
} from "./setup-panel.js";
|
||||
import {
|
||||
initialModelEditorState,
|
||||
transitionModelEditor,
|
||||
type ModelEditorEvent,
|
||||
} from "./model-editor.js";
|
||||
initialModelPickerState,
|
||||
transitionModelPicker,
|
||||
renderModelPicker,
|
||||
modelPickerTitle,
|
||||
type ModelPickerEvent,
|
||||
} from "./model-picker.js";
|
||||
import type {
|
||||
SetupEditableSelectResult,
|
||||
SetupFlowIndicator,
|
||||
@@ -166,7 +167,6 @@ import {
|
||||
formatAssistantResponseStats,
|
||||
formatTokenFlow,
|
||||
formatTurnDuration,
|
||||
typewriterText,
|
||||
isIncompleteOsc,
|
||||
isIncompletePaste,
|
||||
nextKey,
|
||||
@@ -314,10 +314,43 @@ type RenderTurnState = {
|
||||
text: Map<string, string>;
|
||||
reasoning: Map<string, string>;
|
||||
tools: Map<string, NativeToolState>;
|
||||
modelActivity: "Thinking" | "Generating";
|
||||
runningTools: Set<string>;
|
||||
cancelled: boolean;
|
||||
restoreCancelledPrompt: boolean;
|
||||
};
|
||||
|
||||
function turnActivityLabel(state: RenderTurnState | undefined): string {
|
||||
if (state === undefined) return "Thinking";
|
||||
return state.runningTools.size > 0 ? "Running" : state.modelActivity;
|
||||
}
|
||||
|
||||
function updateTurnActivity(state: RenderTurnState, event: AgentTUIStreamEvent): void {
|
||||
switch (event.type) {
|
||||
case "turn-start":
|
||||
case "step-start":
|
||||
case "reasoning-delta":
|
||||
state.modelActivity = "Thinking";
|
||||
break;
|
||||
case "assistant-delta":
|
||||
case "tool-call-preparing":
|
||||
state.modelActivity = "Generating";
|
||||
break;
|
||||
case "assistant-complete":
|
||||
if (event.text?.trim()) state.modelActivity = "Generating";
|
||||
break;
|
||||
case "tool-call":
|
||||
state.runningTools.add(event.toolCallId);
|
||||
state.modelActivity = "Thinking";
|
||||
break;
|
||||
case "tool-result":
|
||||
case "tool-error":
|
||||
case "tool-rejected":
|
||||
state.runningTools.delete(event.toolCallId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
type NativeToolState = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
@@ -347,7 +380,7 @@ const devBuildProgressDelayMs = 250;
|
||||
const devBuildLoadedStatusMs = 4_000;
|
||||
|
||||
const STATUS = {
|
||||
processing: "Working…",
|
||||
processing: "Thinking",
|
||||
connectionAuth: "Waiting for connection authorization…",
|
||||
} as const;
|
||||
|
||||
@@ -359,9 +392,6 @@ const STATUS = {
|
||||
const turnStatsMinDurationMs = 10_000;
|
||||
const turnStatsMinInputTokens = 20_000;
|
||||
|
||||
/** One typed character of the turn bar's label per this many milliseconds. */
|
||||
const turnBarTypewriterMs = 80;
|
||||
|
||||
export class TerminalRenderer implements AgentTUIRenderer {
|
||||
readonly #input: TerminalInput;
|
||||
readonly #output: TerminalOutput;
|
||||
@@ -456,6 +486,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
#hasUserMessage = false;
|
||||
/** Armed by a chat submit; the end-of-turn stats line consumes it. */
|
||||
readonly #turnClock = new TurnClock();
|
||||
#activeTurnState?: RenderTurnState;
|
||||
/**
|
||||
* Draft typed while a turn streams. The prompt row stays in place with
|
||||
* Enter inert (no mid-turn submits yet); the draft seeds the next prompt.
|
||||
@@ -606,7 +637,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
readSelect: (options) => this.#readSetupSelect(options),
|
||||
readEditableSelect: (options) => this.#readSetupEditableSelect(options),
|
||||
readProviderPicker: (options) => this.#readProviderPicker(options),
|
||||
readModelEditor: (options) => this.#readModelEditor(options),
|
||||
readModelPicker: (options) => this.#readModelPicker(options),
|
||||
readText: (options) => this.#readSetupText(options),
|
||||
readAcknowledge: (options) => this.#readSetupAcknowledge(options),
|
||||
readChoice: (options) => this.#readSetupChoice(options),
|
||||
@@ -1002,6 +1033,16 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
this.#assistantOutputTokens = undefined;
|
||||
this.#assistantTokensPerSecond = undefined;
|
||||
this.#streamStartedAt = Date.now();
|
||||
const turnState: RenderTurnState = {
|
||||
text: new Map(),
|
||||
reasoning: new Map(),
|
||||
tools: new Map(),
|
||||
modelActivity: "Thinking",
|
||||
runningTools: new Set(),
|
||||
cancelled: false,
|
||||
restoreCancelledPrompt: true,
|
||||
};
|
||||
this.#activeTurnState = turnState;
|
||||
const displayModes: DisplayModes = {
|
||||
tools: options?.tools ?? this.#tools,
|
||||
reasoning: options?.reasoning ?? this.#reasoning,
|
||||
@@ -1016,14 +1057,6 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
});
|
||||
this.#consumeKey = (key) => this.#handleStreamingKey(key);
|
||||
this.#attachInput();
|
||||
const turnState: RenderTurnState = {
|
||||
text: new Map(),
|
||||
reasoning: new Map(),
|
||||
tools: new Map(),
|
||||
cancelled: false,
|
||||
restoreCancelledPrompt: true,
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const event of takeUntil(iterateTUIStream(result.events), streamInterrupted)) {
|
||||
if (this.#interrupted) break;
|
||||
@@ -1049,6 +1082,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
this.#sendSteering = undefined;
|
||||
this.#detachInput();
|
||||
this.#streamDraftActive = false;
|
||||
this.#activeTurnState = undefined;
|
||||
if (this.#turnIndicator.kind === "waiting") {
|
||||
this.#turnIndicator = { kind: "idle" };
|
||||
}
|
||||
@@ -1094,6 +1128,8 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
text: new Map(),
|
||||
reasoning: new Map(),
|
||||
tools: new Map(),
|
||||
modelActivity: "Thinking",
|
||||
runningTools: new Set(),
|
||||
cancelled: false,
|
||||
restoreCancelledPrompt: false,
|
||||
};
|
||||
@@ -2436,28 +2472,20 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
return await question.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composite Change-model screen: the searchable catalog, the reasoning
|
||||
* slider, and the service-tier toggle on one panel, driven by the pure
|
||||
* model-editor reducer. Resolves the drafted changes on Done, or `undefined`
|
||||
* on Esc/Ctrl-C.
|
||||
*/
|
||||
async #readModelEditor(opts: ModelSettingsRequest): Promise<ModelSettingsResult | undefined> {
|
||||
const flow = this.#beginSetupQuestion("Select the model");
|
||||
let interaction = initialModelEditorState(opts);
|
||||
|
||||
flow.question = (width) =>
|
||||
renderModelEditorQuestion({ request: opts, state: interaction }, this.#theme, width, "");
|
||||
async #readModelPicker(opts: ModelSettingsRequest): Promise<ModelSettingsResult | undefined> {
|
||||
let interaction = initialModelPickerState(opts);
|
||||
const flow = this.#beginSetupQuestion(modelPickerTitle(interaction));
|
||||
flow.question = (width) => renderModelPicker(opts, interaction, this.#theme, width);
|
||||
this.#paint();
|
||||
|
||||
const question = this.#captureSetupQuestion<ModelSettingsResult | undefined>((key, settle) => {
|
||||
const dispatch = (event: ModelEditorEvent): void => {
|
||||
const transition = transitionModelEditor(interaction, event, opts);
|
||||
const dispatch = (event: ModelPickerEvent): void => {
|
||||
const transition = transitionModelPicker(interaction, event, opts);
|
||||
switch (transition.kind) {
|
||||
case "ignore":
|
||||
return;
|
||||
case "render":
|
||||
if (interaction === transition.state) return;
|
||||
interaction = transition.state;
|
||||
flow.questionTitle = modelPickerTitle(interaction);
|
||||
this.#paint();
|
||||
return;
|
||||
case "cancel":
|
||||
@@ -2469,13 +2497,18 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
}
|
||||
};
|
||||
|
||||
if (key.type === "ctrl-c") {
|
||||
dispatch({ type: "cancel" });
|
||||
return;
|
||||
}
|
||||
if (key.type === "escape" || key.type === "left") {
|
||||
dispatch({ type: "back" });
|
||||
return;
|
||||
}
|
||||
const intent = setupSelectionIntent(key);
|
||||
switch (intent?.kind) {
|
||||
case "cancel":
|
||||
dispatch({ type: "cancel" });
|
||||
return;
|
||||
case "move":
|
||||
dispatch({ type: "move", direction: intent.direction });
|
||||
dispatch({ type: intent.direction });
|
||||
return;
|
||||
case "submit":
|
||||
dispatch({ type: "submit" });
|
||||
@@ -2483,21 +2516,6 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
case "repaint":
|
||||
this.#paint();
|
||||
return;
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
|
||||
// Left/right adjust the inline value under the menu cursor, and Tab
|
||||
// mimics right. The shared intent grammar deliberately drops the
|
||||
// horizontal arrows (line editors own them elsewhere), so this surface
|
||||
// consumes them locally.
|
||||
if (key.type === "left" || key.type === "right") {
|
||||
dispatch({ type: "adjust", direction: key.type });
|
||||
return;
|
||||
}
|
||||
if (key.type === "tab") {
|
||||
dispatch({ type: "adjust", direction: "right" });
|
||||
return;
|
||||
}
|
||||
if (key.type === "backspace") {
|
||||
dispatch({ type: "backspace" });
|
||||
@@ -3635,6 +3653,8 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
displayModes: DisplayModes,
|
||||
turnState: RenderTurnState,
|
||||
): void {
|
||||
const previousActivity = turnActivityLabel(turnState);
|
||||
updateTurnActivity(turnState, event);
|
||||
switch (event.type) {
|
||||
case "turn-start":
|
||||
if (event.turnId !== this.#modelTurnId) {
|
||||
@@ -3823,6 +3843,10 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
this.#paint();
|
||||
break;
|
||||
}
|
||||
// Activity is independent of transcript visibility and idle/background streams.
|
||||
if (turnState === this.#activeTurnState && turnActivityLabel(turnState) !== previousActivity) {
|
||||
this.#paint();
|
||||
}
|
||||
}
|
||||
|
||||
#upsertAssistantBlock(id: string, text: string, live: boolean): void {
|
||||
@@ -4481,17 +4505,8 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live turn bar: `▪ Working for 3min 24s ── ↑ 32.4K ↓ 682`. Duration and
|
||||
* token flow tick live on the shared paint beat; the `└`-cornered coda
|
||||
* is this bar's settled form.
|
||||
*/
|
||||
#streamingTurnBar(width: number): string {
|
||||
const c = this.#theme.colors;
|
||||
const pulse = this.#progressPulseGlyph(
|
||||
this.#activityPulseStartedAtMs,
|
||||
this.#theme.unicode ? PROGRESS_PULSE_GLYPH : PROGRESS_PULSE_ASCII_GLYPH,
|
||||
);
|
||||
// A waiting state without an armed turn clock (a /command flash, an
|
||||
// isolated approval) still gets a ticking duration from its own start.
|
||||
const turnIndicator = this.#turnIndicator;
|
||||
@@ -4499,21 +4514,22 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
this.#turnClock.startedAtMs ??
|
||||
this.#streamStartedAt ??
|
||||
(turnIndicator.kind === "waiting" ? turnIndicator.startedAtMs : Date.now());
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
// Anchored to the turn clock, the label's reveal plays once per turn —
|
||||
// a question answer's continuation pass resumes fully typed.
|
||||
const label = typewriterText("Working for", elapsedMs, turnBarTypewriterMs);
|
||||
const body = `${label} ${this.#turnStatsBody(elapsedMs)}`;
|
||||
// Column 0: the bar shares the gutter with the conversation markers and
|
||||
// its own settled `└` coda.
|
||||
return clip(`${c.yellow(pulse)} ${c.dim(body)}`, width);
|
||||
const elapsedMs = Math.max(0, Date.now() - startedAtMs);
|
||||
const marker = elapsedMs % 1000 < 500 ? (this.#theme.unicode ? "•" : "*") : " ";
|
||||
const elapsed =
|
||||
elapsedMs < 1000 ? "0s" : formatTurnDuration(Math.floor(elapsedMs / 1000) * 1000);
|
||||
const label = `${turnActivityLabel(this.#activeTurnState)} (${elapsed})`;
|
||||
const { inputTokens, outputTokens } = this.#turnClock.usage;
|
||||
const tokens =
|
||||
inputTokens > 0 || outputTokens > 0
|
||||
? c.dim(` (${formatTokenFlow({ inputTokens, outputTokens }, this.#theme.glyph)})`)
|
||||
: "";
|
||||
return clip(`${marker} ${label}${tokens}`, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared body of the live turn bar and the settled coda:
|
||||
* `3min 24s ── ↑ 32.4K ↓ 682` (token flow only once the turn has moved a
|
||||
* token). `MockScreen.waitForIdlePrompt` recognizes the live bar by its
|
||||
* typewriter `Working` label followed by this duration.
|
||||
* The settled coda: `3min 24s ── ↑ 32.4K ↓ 682`, with token flow only once
|
||||
* the turn has moved a token.
|
||||
*/
|
||||
#turnStatsBody(elapsedMs: number): string {
|
||||
return `${formatTurnDuration(elapsedMs)}${this.#turnFlowSuffix()}`;
|
||||
|
||||
@@ -5,7 +5,7 @@ export function createFakeSetupFlowRenderer(
|
||||
): SetupFlowRenderer {
|
||||
const {
|
||||
readProviderPicker = async () => undefined,
|
||||
readModelEditor = async () => undefined,
|
||||
readModelPicker = async () => undefined,
|
||||
...rest
|
||||
} = overrides;
|
||||
return {
|
||||
@@ -14,7 +14,7 @@ export function createFakeSetupFlowRenderer(
|
||||
readSelect: async () => undefined,
|
||||
readEditableSelect: async () => undefined,
|
||||
readProviderPicker,
|
||||
readModelEditor,
|
||||
readModelPicker,
|
||||
readText: async () => undefined,
|
||||
readAcknowledge: async () => {},
|
||||
readChoice: () => ({ choice: Promise.resolve(undefined), close: () => {} }),
|
||||
|
||||
@@ -133,16 +133,9 @@ export class MockScreen extends EventEmitter implements TerminalOutput {
|
||||
* readiness — the bar's absence is the discriminator.
|
||||
*/
|
||||
async waitForIdlePrompt(timeoutMs = 1000) {
|
||||
// The live turn bar: `▪ Working for <duration>…` at column 0 (pulse-off
|
||||
// frames blank the mark; the label may be mid-typewriter but is always
|
||||
// an exact prefix of "Working for", and the duration follows it). The
|
||||
// exact-prefix alternation plus the digit keeps it from matching the
|
||||
// `└ Done in …` coda, prompt rows, ordinary prose, and the todo panel.
|
||||
const barLabel = "Working for";
|
||||
const labelPrefixes = Array.from({ length: barLabel.length }, (_, index) =>
|
||||
barLabel.slice(0, index + 1),
|
||||
);
|
||||
const liveTurnBar = new RegExp(`^[▪* ] (?:${labelPrefixes.join("|")}) \\d`, "mu");
|
||||
// Match the activity row even while its dot is hidden, without treating
|
||||
// the completed coda, a prompt, or ordinary prose as active work.
|
||||
const liveTurnBar = /^[•* ] (?:Thinking|Generating|Running) \(\d/mu;
|
||||
// Unicode glyphs only: the ASCII prompt mark (`>`) is ambiguous with the
|
||||
// ASCII brand mark, and every TUI smoke script pins EVE_TUI_UNICODE=1.
|
||||
const idle = () => {
|
||||
|
||||
@@ -117,14 +117,6 @@ export interface ThemeGlyphs {
|
||||
arrowDown: string;
|
||||
/** `↯` — Fast mode (Gateway priority tier) marker beside a model id. */
|
||||
fast: string;
|
||||
/** `●` — a track notch below the current position (already covered). */
|
||||
trackFilled: string;
|
||||
/** `◉` — the track notch at the current position. */
|
||||
trackCurrent: string;
|
||||
/** `○` — a track notch above the current position. */
|
||||
trackEmpty: string;
|
||||
/** `─` — the connector segment between track notches. */
|
||||
trackLine: string;
|
||||
/** `↵` — the Enter affordance inside a selection badge. */
|
||||
enter: string;
|
||||
/** `▪` — the marker beside an in-flight validation badge. */
|
||||
@@ -161,10 +153,6 @@ const UNICODE_GLYPHS: ThemeGlyphs = {
|
||||
arrowUp: "↑",
|
||||
arrowDown: "↓",
|
||||
fast: "↯",
|
||||
trackFilled: "●",
|
||||
trackCurrent: "◉",
|
||||
trackEmpty: "○",
|
||||
trackLine: "─",
|
||||
enter: "↵",
|
||||
validating: "▪",
|
||||
};
|
||||
@@ -199,10 +187,6 @@ const ASCII_GLYPHS: ThemeGlyphs = {
|
||||
arrowUp: "^",
|
||||
arrowDown: "v",
|
||||
fast: ">>",
|
||||
trackFilled: "*",
|
||||
trackCurrent: "O",
|
||||
trackEmpty: ".",
|
||||
trackLine: "-",
|
||||
enter: "<-'",
|
||||
validating: "o",
|
||||
};
|
||||
|
||||
@@ -49,8 +49,8 @@ export interface CurrentAgentModel {
|
||||
export type { GatewayServiceTierState };
|
||||
|
||||
/**
|
||||
* Everything the composite Change-model screen edits, resolved before it
|
||||
* opens. The model section is a searchable catalog pick, or a fixed line when
|
||||
* The model picker inputs, resolved before it opens. The first step is a
|
||||
* searchable catalog, or a fixed model when
|
||||
* the authored model is a source-backed SDK call `/model` cannot rewrite.
|
||||
*/
|
||||
export interface ModelSettingsRequest {
|
||||
@@ -79,7 +79,7 @@ export interface ModelSettingsResult {
|
||||
serviceTier?: "standard" | "priority";
|
||||
}
|
||||
|
||||
/** Renderer-owned composite model screen; only the dev TUI implements this. */
|
||||
/** Renderer-owned model picker; only the dev TUI implements this. */
|
||||
export type ModelSettingsPicker = (
|
||||
request: ModelSettingsRequest,
|
||||
) => Promise<ModelSettingsResult | undefined>;
|
||||
@@ -98,7 +98,7 @@ export interface ModelFlowDeps {
|
||||
}) => Promise<ApplyModelSettingsOutcome>;
|
||||
/** Catalog fetch behind the shared model picker. */
|
||||
selectModel?: SelectModelDeps;
|
||||
/** The composite Change-model screen; the dev TUI renderer implements it. */
|
||||
/** The model picker; the dev TUI renderer implements it. */
|
||||
pickModelSettings?: ModelSettingsPicker;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export type ModelFlowResult =
|
||||
providerSelection?: ProviderSelection;
|
||||
};
|
||||
|
||||
/** Selects a model or setting and applies the completed edit immediately. */
|
||||
/** Selects a model, speed, and reasoning, then applies the completed selection together. */
|
||||
export async function runModelFlow(input: {
|
||||
/** Selected agent root whose authored model settings are edited. */
|
||||
appRoot: string;
|
||||
|
||||
@@ -48,7 +48,7 @@ run({ app: "agent-tui-client", kind: "local-build" }, async (target) => {
|
||||
// A long first turn holds the stream open while Enter submits steering.
|
||||
input.type("Write a short story of about 150 words about tides. Do not use any tools.");
|
||||
input.enter();
|
||||
await screen.waitForText("Working for", 30_000);
|
||||
await waitForActiveTurn(screen, 30_000);
|
||||
|
||||
input.type(`Reply with one short sentence containing the token ${STEER_TOKEN}.`);
|
||||
const steeringOutputStart = screen.rawOutput().length;
|
||||
@@ -65,7 +65,7 @@ run({ app: "agent-tui-client", kind: "local-build" }, async (target) => {
|
||||
const cancellationOutputStart = screen.rawOutput().length;
|
||||
input.type("Write a story of about 500 words about lighthouses. Do not use any tools.");
|
||||
input.enter();
|
||||
await screen.waitForText("Working for", 30_000);
|
||||
await waitForActiveTurn(screen, 30_000);
|
||||
|
||||
// With no queued message, the first Esc cooperatively cancels the turn.
|
||||
input.emit("data", Buffer.from("\x1b"));
|
||||
@@ -92,6 +92,18 @@ run({ app: "agent-tui-client", kind: "local-build" }, async (target) => {
|
||||
await runPromise;
|
||||
});
|
||||
|
||||
async function waitForActiveTurn(screen: MockScreen, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const snapshot = screen.snapshot();
|
||||
if (/^[•* ] (?:Thinking|Generating|Running) \(\d/mu.test(snapshot) && /^❯/mu.test(snapshot)) {
|
||||
return;
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error(`Timed out waiting for an active turn.\n\nScreen:\n${screen.snapshot()}`);
|
||||
}
|
||||
|
||||
/** Waits until `token` appears at least twice: the echoed prompt and the reply. */
|
||||
async function waitForTwice(
|
||||
screen: MockScreen,
|
||||
|
||||
Reference in New Issue
Block a user