@elench/testkit 0.1.91 → 0.1.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,8 @@ cd my-product
13
13
 
14
14
  # Launch the interactive assistant
15
15
  npx @elench/testkit
16
+ npx @elench/testkit assistant --provider codex --model gpt-5.4
17
+ npx @elench/testkit assistant --provider claude --model sonnet --effort high
16
18
 
17
19
  # Ask for one assistant turn non-interactively
18
20
  npx @elench/testkit assistant --message "/status"
@@ -82,6 +84,14 @@ blocks for command execution. Natural-language turns still go through Codex or
82
84
  Claude, but `testkit` owns the transcript, command execution surface, and
83
85
  rendering around `testkit`, `npm`, and `npx` commands.
84
86
 
87
+ Assistant runtime settings are repo-local. Use `/provider`, `/model`,
88
+ `/effort`, and `/settings` inside the assistant to inspect or change the active
89
+ provider runtime; changes are persisted to `.testkit/assistant/settings.json`.
90
+ CLI flags such as `--provider`, `--model`, `--effort`, and repeatable
91
+ `--provider-arg` override those settings for the current launch. The composer
92
+ has an always-visible cursor and supports arrow keys, Home/End, Ctrl+A/Ctrl+E,
93
+ Backspace, Delete, and Ctrl+D.
94
+
85
95
  The non-interactive `assistant --message ...` mode uses the same provider/tool
86
96
  engine for one hosted turn at a time. It is useful in scripts and tests, but
87
97
  it is not the primary interactive UX.
@@ -57,6 +57,9 @@ export function isProviderInstalled(provider, env = process.env) {
57
57
 
58
58
  export function startAgentSession({
59
59
  provider = "auto",
60
+ model = null,
61
+ effort = null,
62
+ providerArgs = [],
60
63
  cwd,
61
64
  prompt,
62
65
  onEvent,
@@ -66,7 +69,7 @@ export function startAgentSession({
66
69
  const resolvedProvider = resolvePreferredProvider(provider, env);
67
70
  const command = resolveProviderBinary(resolvedProvider, env);
68
71
  if (resolvedProvider === "claude") {
69
- return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose });
72
+ return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose, model, effort, providerArgs });
70
73
  }
71
- return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose });
74
+ return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose, model, providerArgs });
72
75
  }
@@ -7,7 +7,16 @@ import {
7
7
  extractTextFragments,
8
8
  } from "./shared.mjs";
9
9
 
10
- export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEvent, purpose = "assistant" } = {}) {
10
+ export function startClaudeHostedSession({
11
+ command = "claude",
12
+ cwd,
13
+ prompt,
14
+ onEvent,
15
+ purpose = "assistant",
16
+ model = null,
17
+ effort = null,
18
+ providerArgs = [],
19
+ } = {}) {
11
20
  const args = [
12
21
  "-p",
13
22
  "--output-format",
@@ -18,11 +27,19 @@ export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEv
18
27
  if (purpose === "assistant") {
19
28
  args.push("--permission-mode", "plan");
20
29
  }
30
+ if (model) {
31
+ args.push("--model", String(model));
32
+ }
33
+ if (effort) {
34
+ args.push("--effort", String(effort));
35
+ }
36
+ args.push(...normalizeProviderArgs(providerArgs));
21
37
 
22
38
  args.push(prompt);
23
39
 
24
40
  const child = execa(command, args, {
25
41
  cwd,
42
+ stdin: "ignore",
26
43
  stdout: "pipe",
27
44
  stderr: "pipe",
28
45
  reject: false,
@@ -39,6 +56,11 @@ export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEv
39
56
  });
40
57
  }
41
58
 
59
+ function normalizeProviderArgs(providerArgs) {
60
+ if (!Array.isArray(providerArgs)) return [];
61
+ return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
62
+ }
63
+
42
64
  function parseClaudePayload(payload) {
43
65
  const events = [];
44
66
  if (!payload || typeof payload !== "object") return events;
@@ -11,7 +11,15 @@ import {
11
11
  readTextFileIfPresent,
12
12
  } from "./shared.mjs";
13
13
 
14
- export function startCodexHostedSession({ command = "codex", cwd, prompt, onEvent, purpose = "assistant" } = {}) {
14
+ export function startCodexHostedSession({
15
+ command = "codex",
16
+ cwd,
17
+ prompt,
18
+ onEvent,
19
+ purpose = "assistant",
20
+ model = null,
21
+ providerArgs = [],
22
+ } = {}) {
15
23
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-codex-"));
16
24
  const outputFile = path.join(tempDir, "final-message.txt");
17
25
  const args = ["exec", "--json", "-o", outputFile];
@@ -19,11 +27,16 @@ export function startCodexHostedSession({ command = "codex", cwd, prompt, onEven
19
27
  if (purpose === "assistant") {
20
28
  args.push("-s", "read-only");
21
29
  }
30
+ if (model) {
31
+ args.push("--model", String(model));
32
+ }
33
+ args.push(...normalizeProviderArgs(providerArgs));
22
34
 
23
35
  args.push(prompt);
24
36
 
25
37
  const child = execa(command, args, {
26
38
  cwd,
39
+ stdin: "ignore",
27
40
  stdout: "pipe",
28
41
  stderr: "pipe",
29
42
  reject: false,
@@ -34,6 +47,9 @@ export function startCodexHostedSession({ command = "codex", cwd, prompt, onEven
34
47
  child,
35
48
  onEvent,
36
49
  parsePayload: parseCodexPayload,
50
+ shouldIgnoreStatus(message) {
51
+ return String(message || "").trim() === "Reading additional input from stdin...";
52
+ },
37
53
  readFinalText(result) {
38
54
  return readTextFileIfPresent(outputFile) || result.stdout || null;
39
55
  },
@@ -49,6 +65,11 @@ export function startCodexHostedSession({ command = "codex", cwd, prompt, onEven
49
65
  };
50
66
  }
51
67
 
68
+ function normalizeProviderArgs(providerArgs) {
69
+ if (!Array.isArray(providerArgs)) return [];
70
+ return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
71
+ }
72
+
52
73
  function parseCodexPayload(payload) {
53
74
  const events = [];
54
75
  if (!payload || typeof payload !== "object") return events;
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import readline from "readline";
3
3
 
4
- export function createHostedSessionRunner({ provider, child, onEvent, parsePayload, readFinalText } = {}) {
4
+ export function createHostedSessionRunner({ provider, child, onEvent, parsePayload, readFinalText, shouldIgnoreStatus } = {}) {
5
5
  let cancelled = false;
6
6
  let settled = false;
7
7
  let assistantText = "";
@@ -29,6 +29,7 @@ export function createHostedSessionRunner({ provider, child, onEvent, parsePaylo
29
29
 
30
30
  const stderrReader = readline.createInterface({ input: child.stderr });
31
31
  stderrReader.on("line", (line) => {
32
+ if (shouldIgnoreStatus?.(line)) return;
32
33
  emit({ type: "status", message: line });
33
34
  });
34
35
 
@@ -1,6 +1,7 @@
1
1
  import React, { createElement, useEffect, useMemo, useState } from "react";
2
2
  import { Box, Text, useApp, useInput } from "ink";
3
3
  import { bold, dim, green, red, yellow } from "../presentation/colors.mjs";
4
+ import { getComposerRenderParts } from "./composer.mjs";
4
5
 
5
6
  const MAX_VISIBLE_MESSAGES = 22;
6
7
 
@@ -113,11 +114,11 @@ function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
113
114
  assistantState.moveComposerCursorToEnd();
114
115
  return;
115
116
  }
116
- if (key.backspace || key.delete) {
117
+ if (key.backspace) {
117
118
  assistantState.backspaceComposer();
118
119
  return;
119
120
  }
120
- if (key.ctrl && input === "d") {
121
+ if (key.delete || (key.ctrl && input === "d")) {
121
122
  assistantState.deleteComposer();
122
123
  return;
123
124
  }
@@ -154,19 +155,14 @@ function renderMessage(message) {
154
155
  }
155
156
 
156
157
  function renderComposer(snapshot) {
157
- const composer = snapshot.composer || "";
158
- const cursor = snapshot.composerCursor ?? composer.length;
159
- const before = composer.slice(0, cursor);
160
- const current = composer[cursor] || " ";
161
- const after = composer.slice(cursor + (composer[cursor] ? 1 : 0));
162
- const placeholder = composer.length === 0 ? dim("Ask testkit to run or inspect something...") : "";
163
- if (composer.length === 0) {
164
- return createElement(Text, null, placeholder);
165
- }
158
+ const { before, current, after, empty } = getComposerRenderParts({
159
+ text: snapshot.composer || "",
160
+ cursor: snapshot.composerCursor ?? 0,
161
+ });
166
162
  return createElement(
167
163
  Text,
168
164
  null,
169
- before,
165
+ empty ? dim("Ask testkit to run or inspect something... ") : before,
170
166
  createElement(Text, { inverse: true }, current),
171
167
  after
172
168
  );
@@ -175,8 +171,11 @@ function renderComposer(snapshot) {
175
171
  function buildHeader(snapshot) {
176
172
  const status = snapshot.busy ? snapshot.activeStatus || "working" : "ready";
177
173
  const provider = snapshot.provider || "auto";
174
+ const resolvedProvider = snapshot.resolvedProvider && snapshot.resolvedProvider !== provider ? `→${snapshot.resolvedProvider}` : "";
175
+ const model = snapshot.model ? ` · ${snapshot.model}` : "";
176
+ const effort = snapshot.effort ? ` · ${snapshot.effort}` : "";
178
177
  const context = snapshot.context?.selection?.filePath || snapshot.context?.selection?.serviceName || "no focus";
179
- return `testkit assistant · ${provider} · ${status} · ${context}`;
178
+ return `testkit assistant · ${provider}${resolvedProvider}${model}${effort} · ${status} · ${context}`;
180
179
  }
181
180
 
182
181
  function buildFooter(snapshot, promptFinished) {
@@ -186,7 +185,7 @@ function buildFooter(snapshot, promptFinished) {
186
185
  if (snapshot.busy) {
187
186
  return "Enter disabled while the provider is responding · Ctrl+C quit";
188
187
  }
189
- return "Enter send · Ctrl+A/Ctrl+E move cursor · Backspace delete · q quit";
188
+ return "Enter send · arrows/Home/End move cursor · Backspace/Delete edit · /settings · q quit";
190
189
  }
191
190
 
192
191
  function rolePrefix(message) {
@@ -0,0 +1,112 @@
1
+ const segmenter =
2
+ typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
3
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
+ : null;
5
+
6
+ export function createComposerState(value = "") {
7
+ const text = String(value || "");
8
+ return {
9
+ text,
10
+ cursor: splitGraphemes(text).length,
11
+ };
12
+ }
13
+
14
+ export function setComposerText(state, value) {
15
+ const text = String(value || "");
16
+ const length = splitGraphemes(text).length;
17
+ return {
18
+ text,
19
+ cursor: clampCursor(state?.cursor ?? length, length),
20
+ };
21
+ }
22
+
23
+ export function insertComposerText(state, value) {
24
+ const insertText = String(value || "");
25
+ if (!insertText) return normalizeComposerState(state);
26
+ const parts = splitGraphemes(state?.text || "");
27
+ const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
28
+ const insertParts = splitGraphemes(insertText);
29
+ const nextParts = [...parts.slice(0, cursor), ...insertParts, ...parts.slice(cursor)];
30
+ return {
31
+ text: nextParts.join(""),
32
+ cursor: cursor + insertParts.length,
33
+ };
34
+ }
35
+
36
+ export function backspaceComposerText(state) {
37
+ const parts = splitGraphemes(state?.text || "");
38
+ const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
39
+ if (cursor === 0) return { text: parts.join(""), cursor };
40
+ const nextParts = [...parts.slice(0, cursor - 1), ...parts.slice(cursor)];
41
+ return {
42
+ text: nextParts.join(""),
43
+ cursor: cursor - 1,
44
+ };
45
+ }
46
+
47
+ export function deleteComposerText(state) {
48
+ const parts = splitGraphemes(state?.text || "");
49
+ const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
50
+ if (cursor >= parts.length) return { text: parts.join(""), cursor };
51
+ const nextParts = [...parts.slice(0, cursor), ...parts.slice(cursor + 1)];
52
+ return {
53
+ text: nextParts.join(""),
54
+ cursor,
55
+ };
56
+ }
57
+
58
+ export function moveComposerCursor(state, delta) {
59
+ const parts = splitGraphemes(state?.text || "");
60
+ return {
61
+ text: parts.join(""),
62
+ cursor: clampCursor((state?.cursor ?? parts.length) + Number(delta || 0), parts.length),
63
+ };
64
+ }
65
+
66
+ export function moveComposerCursorToStart(state) {
67
+ return {
68
+ text: String(state?.text || ""),
69
+ cursor: 0,
70
+ };
71
+ }
72
+
73
+ export function moveComposerCursorToEnd(state) {
74
+ const text = String(state?.text || "");
75
+ return {
76
+ text,
77
+ cursor: splitGraphemes(text).length,
78
+ };
79
+ }
80
+
81
+ export function getComposerRenderParts(state) {
82
+ const parts = splitGraphemes(state?.text || "");
83
+ const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
84
+ return {
85
+ before: parts.slice(0, cursor).join(""),
86
+ current: parts[cursor] || " ",
87
+ after: parts.slice(cursor + (parts[cursor] ? 1 : 0)).join(""),
88
+ empty: parts.length === 0,
89
+ };
90
+ }
91
+
92
+ function normalizeComposerState(state) {
93
+ const text = String(state?.text || "");
94
+ const parts = splitGraphemes(text);
95
+ return {
96
+ text,
97
+ cursor: clampCursor(state?.cursor ?? parts.length, parts.length),
98
+ };
99
+ }
100
+
101
+ function splitGraphemes(value) {
102
+ const text = String(value || "");
103
+ if (!text) return [];
104
+ if (segmenter) {
105
+ return [...segmenter.segment(text)].map((entry) => entry.segment);
106
+ }
107
+ return [...text];
108
+ }
109
+
110
+ function clampCursor(value, length) {
111
+ return Math.max(0, Math.min(Number(value || 0), length));
112
+ }
@@ -6,7 +6,11 @@ import { loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
6
6
 
7
7
  export async function runInteractiveAssistant({
8
8
  productDir,
9
- provider = "auto",
9
+ provider,
10
+ model,
11
+ effort,
12
+ providerArgs,
13
+ resetSettings = false,
10
14
  file = null,
11
15
  service = null,
12
16
  prompt = null,
@@ -18,6 +22,10 @@ export async function runInteractiveAssistant({
18
22
  const assistantState = createAssistantState({
19
23
  productDir,
20
24
  provider,
25
+ model,
26
+ effort,
27
+ providerArgs,
28
+ resetSettings,
21
29
  configs,
22
30
  env,
23
31
  });
@@ -9,11 +9,13 @@ export async function runAssistantConversationTurn({
9
9
  transcript,
10
10
  userMessage,
11
11
  provider = "auto",
12
+ settings = null,
12
13
  env = process.env,
13
14
  configs,
14
15
  commandLog,
15
16
  onStatus,
16
17
  onToolEvent,
18
+ onResolvedProvider,
17
19
  } = {}) {
18
20
  const tools = listAssistantTools();
19
21
  const toolContext = {
@@ -38,10 +40,16 @@ export async function runAssistantConversationTurn({
38
40
  userMessage,
39
41
  });
40
42
 
41
- onStatus?.(`Thinking with ${resolvePreferredProvider(provider, env)}...`);
43
+ const runtimeSettings = settings || { provider };
44
+ const resolvedProvider = resolvePreferredProvider(runtimeSettings.provider || provider, env);
45
+ onResolvedProvider?.(resolvedProvider);
46
+ onStatus?.(`Thinking with ${resolvedProvider}...`);
42
47
  const events = [];
43
48
  const session = startAgentSession({
44
- provider,
49
+ provider: runtimeSettings.provider || provider,
50
+ model: runtimeSettings.model || null,
51
+ effort: runtimeSettings.effort || null,
52
+ providerArgs: runtimeSettings.providerArgs || [],
45
53
  cwd: productDir,
46
54
  prompt,
47
55
  purpose: "assistant",
@@ -0,0 +1,98 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export const ASSISTANT_PROVIDERS = ["auto", "codex", "claude"];
5
+ export const ASSISTANT_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
6
+
7
+ export const DEFAULT_ASSISTANT_SETTINGS = Object.freeze({
8
+ provider: "auto",
9
+ model: null,
10
+ effort: null,
11
+ providerArgs: [],
12
+ });
13
+
14
+ export function assistantSettingsPath(productDir) {
15
+ return path.join(productDir || process.cwd(), ".testkit", "assistant", "settings.json");
16
+ }
17
+
18
+ export function loadAssistantSettings(productDir) {
19
+ const filePath = assistantSettingsPath(productDir);
20
+ try {
21
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
22
+ return normalizeAssistantSettings(parsed);
23
+ } catch {
24
+ return { ...DEFAULT_ASSISTANT_SETTINGS };
25
+ }
26
+ }
27
+
28
+ export function saveAssistantSettings(productDir, settings) {
29
+ const filePath = assistantSettingsPath(productDir);
30
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
31
+ fs.writeFileSync(filePath, `${JSON.stringify(normalizeAssistantSettings(settings), null, 2)}\n`);
32
+ }
33
+
34
+ export function resetAssistantSettings(productDir) {
35
+ const filePath = assistantSettingsPath(productDir);
36
+ fs.rmSync(filePath, { force: true });
37
+ return { ...DEFAULT_ASSISTANT_SETTINGS };
38
+ }
39
+
40
+ export function mergeAssistantSettings(...settingsObjects) {
41
+ let merged = { ...DEFAULT_ASSISTANT_SETTINGS };
42
+ for (const settings of settingsObjects) {
43
+ if (!settings || typeof settings !== "object") continue;
44
+ merged = normalizeAssistantSettings({
45
+ ...merged,
46
+ ...dropNullishSettings(settings),
47
+ providerArgs: settings.providerArgs == null ? merged.providerArgs : settings.providerArgs,
48
+ });
49
+ }
50
+ return merged;
51
+ }
52
+
53
+ export function normalizeAssistantSettings(value = {}) {
54
+ const provider = normalizeProvider(value.provider);
55
+ const effort = normalizeEffort(value.effort);
56
+ const model = normalizeOptionalString(value.model);
57
+ const providerArgs = Array.isArray(value.providerArgs)
58
+ ? value.providerArgs.map((entry) => normalizeOptionalString(entry)).filter(Boolean)
59
+ : [];
60
+
61
+ return {
62
+ provider,
63
+ model,
64
+ effort,
65
+ providerArgs,
66
+ };
67
+ }
68
+
69
+ export function normalizeProvider(value) {
70
+ const provider = normalizeOptionalString(value) || DEFAULT_ASSISTANT_SETTINGS.provider;
71
+ if (!ASSISTANT_PROVIDERS.includes(provider)) {
72
+ throw new Error(`Assistant provider must be one of: ${ASSISTANT_PROVIDERS.join(", ")}`);
73
+ }
74
+ return provider;
75
+ }
76
+
77
+ export function normalizeEffort(value) {
78
+ const effort = normalizeOptionalString(value);
79
+ if (!effort) return null;
80
+ if (!ASSISTANT_EFFORTS.includes(effort)) {
81
+ throw new Error(`Assistant effort must be one of: ${ASSISTANT_EFFORTS.join(", ")}`);
82
+ }
83
+ return effort;
84
+ }
85
+
86
+ export function normalizeOptionalString(value) {
87
+ if (value == null) return null;
88
+ const stringValue = String(value).trim();
89
+ return stringValue || null;
90
+ }
91
+
92
+ function dropNullishSettings(settings) {
93
+ const result = {};
94
+ for (const [key, value] of Object.entries(settings)) {
95
+ if (value !== undefined) result[key] = value;
96
+ }
97
+ return result;
98
+ }
@@ -1,5 +1,8 @@
1
1
  const RUN_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
2
- const PROVIDERS = new Set(["auto", "claude", "codex"]);
2
+ import { ASSISTANT_EFFORTS, ASSISTANT_PROVIDERS } from "./settings.mjs";
3
+
4
+ const PROVIDERS = new Set(ASSISTANT_PROVIDERS);
5
+ const EFFORTS = new Set(ASSISTANT_EFFORTS);
3
6
 
4
7
  export function parseSlashCommand(input) {
5
8
  const trimmed = String(input || "").trim();
@@ -19,6 +22,40 @@ export function parseSlashCommand(input) {
19
22
  return { type: "provider", provider };
20
23
  }
21
24
 
25
+ if (command === "model") {
26
+ const model = tokens.join(" ").trim();
27
+ if (!model) throw new Error("/model expects a model name or default");
28
+ return { type: "model", model: model === "default" ? null : model };
29
+ }
30
+
31
+ if (command === "effort") {
32
+ const effort = tokens[0] || "";
33
+ if (effort === "default") return { type: "effort", effort: null };
34
+ if (!EFFORTS.has(effort)) {
35
+ throw new Error(`/effort expects one of: ${[...EFFORTS, "default"].join(", ")}`);
36
+ }
37
+ return { type: "effort", effort };
38
+ }
39
+
40
+ if (command === "provider-arg") {
41
+ const action = tokens.shift() || "list";
42
+ if (action === "list") return { type: "provider-args-list" };
43
+ if (action === "clear") return { type: "provider-args-clear" };
44
+ if (action === "add") {
45
+ const value = tokens.join(" ").trim();
46
+ if (!value) throw new Error("/provider-arg add expects an argument");
47
+ return { type: "provider-args-add", value };
48
+ }
49
+ throw new Error('/provider-arg expects "add", "list", or "clear"');
50
+ }
51
+
52
+ if (command === "settings") {
53
+ const action = tokens[0] || "show";
54
+ if (action === "show") return { type: "settings-show" };
55
+ if (action === "reset") return { type: "settings-reset" };
56
+ throw new Error('/settings expects "show" or "reset"');
57
+ }
58
+
22
59
  if (command === "file" || command === "focus") {
23
60
  if (!tokens[0]) throw new Error(`/${command} expects a file path`);
24
61
  return { type: "file", file: tokens.join(" ") };
@@ -84,6 +121,13 @@ export function formatSlashHelpLines() {
84
121
  "/status",
85
122
  "/doctor",
86
123
  "/provider <auto|claude|codex>",
124
+ "/model <model|default>",
125
+ "/effort <low|medium|high|xhigh|max|default>",
126
+ "/provider-arg add <arg>",
127
+ "/provider-arg list",
128
+ "/provider-arg clear",
129
+ "/settings",
130
+ "/settings reset",
87
131
  "/clear",
88
132
  "/quit",
89
133
  ];
@@ -5,10 +5,31 @@ import { parseSlashCommand, formatSlashHelpLines } from "./slash-commands.mjs";
5
5
  import { executeAssistantTool } from "./tool-registry.mjs";
6
6
  import { runAssistantConversationTurn } from "./session.mjs";
7
7
  import { prepareAssistantContextPack } from "./context-pack.mjs";
8
+ import {
9
+ DEFAULT_ASSISTANT_SETTINGS,
10
+ loadAssistantSettings,
11
+ mergeAssistantSettings,
12
+ resetAssistantSettings,
13
+ saveAssistantSettings,
14
+ } from "./settings.mjs";
15
+ import {
16
+ backspaceComposerText,
17
+ createComposerState,
18
+ deleteComposerText,
19
+ insertComposerText,
20
+ moveComposerCursor as moveComposerCursorState,
21
+ moveComposerCursorToEnd as moveComposerCursorStateToEnd,
22
+ moveComposerCursorToStart as moveComposerCursorStateToStart,
23
+ setComposerText,
24
+ } from "./composer.mjs";
8
25
 
9
26
  export function createAssistantState({
10
27
  productDir,
11
- provider = "auto",
28
+ provider,
29
+ model,
30
+ effort,
31
+ providerArgs,
32
+ resetSettings = false,
12
33
  dataSource = "artifact",
13
34
  configs = [],
14
35
  env = process.env,
@@ -21,11 +42,19 @@ export function createAssistantState({
21
42
 
22
43
  const listeners = new Set();
23
44
  const messages = [];
24
- let composer = "";
25
- let composerCursor = 0;
45
+ let composerState = createComposerState();
26
46
  let notice = null;
27
47
  let busy = false;
28
- let providerName = provider;
48
+ let settings = mergeAssistantSettings(
49
+ resetSettings ? resetAssistantSettings(productDir) : loadAssistantSettings(productDir),
50
+ {
51
+ provider,
52
+ model,
53
+ effort,
54
+ providerArgs,
55
+ }
56
+ );
57
+ let resolvedProviderName = null;
29
58
  let activeStatus = null;
30
59
 
31
60
  inspectState.subscribe(() => {
@@ -90,16 +119,12 @@ export function createAssistantState({
90
119
  },
91
120
 
92
121
  setComposer(value) {
93
- composer = String(value || "");
94
- composerCursor = Math.max(0, Math.min(composer.length, composerCursor));
122
+ composerState = setComposerText(composerState, value);
95
123
  notify();
96
124
  },
97
125
 
98
126
  insertComposer(text) {
99
- const nextText = String(text || "");
100
- if (!nextText) return;
101
- composer = `${composer.slice(0, composerCursor)}${nextText}${composer.slice(composerCursor)}`;
102
- composerCursor += nextText.length;
127
+ composerState = insertComposerText(composerState, text);
103
128
  notify();
104
129
  },
105
130
 
@@ -108,30 +133,27 @@ export function createAssistantState({
108
133
  },
109
134
 
110
135
  backspaceComposer() {
111
- if (composerCursor === 0) return;
112
- composer = `${composer.slice(0, composerCursor - 1)}${composer.slice(composerCursor)}`;
113
- composerCursor -= 1;
136
+ composerState = backspaceComposerText(composerState);
114
137
  notify();
115
138
  },
116
139
 
117
140
  deleteComposer() {
118
- if (composerCursor >= composer.length) return;
119
- composer = `${composer.slice(0, composerCursor)}${composer.slice(composerCursor + 1)}`;
141
+ composerState = deleteComposerText(composerState);
120
142
  notify();
121
143
  },
122
144
 
123
145
  moveComposerCursor(delta) {
124
- composerCursor = Math.max(0, Math.min(composer.length, composerCursor + delta));
146
+ composerState = moveComposerCursorState(composerState, delta);
125
147
  notify();
126
148
  },
127
149
 
128
150
  moveComposerCursorToStart() {
129
- composerCursor = 0;
151
+ composerState = moveComposerCursorStateToStart(composerState);
130
152
  notify();
131
153
  },
132
154
 
133
155
  moveComposerCursorToEnd() {
134
- composerCursor = composer.length;
156
+ composerState = moveComposerCursorStateToEnd(composerState);
135
157
  notify();
136
158
  },
137
159
 
@@ -146,7 +168,41 @@ export function createAssistantState({
146
168
  },
147
169
 
148
170
  setProvider(nextProvider) {
149
- providerName = nextProvider || "auto";
171
+ settings = mergeAssistantSettings(settings, { provider: nextProvider || DEFAULT_ASSISTANT_SETTINGS.provider });
172
+ resolvedProviderName = null;
173
+ saveAssistantSettings(productDir, settings);
174
+ notify();
175
+ },
176
+
177
+ setModel(nextModel) {
178
+ settings = mergeAssistantSettings(settings, { model: nextModel || null });
179
+ saveAssistantSettings(productDir, settings);
180
+ notify();
181
+ },
182
+
183
+ setEffort(nextEffort) {
184
+ settings = mergeAssistantSettings(settings, { effort: nextEffort || null });
185
+ saveAssistantSettings(productDir, settings);
186
+ notify();
187
+ },
188
+
189
+ addProviderArg(value) {
190
+ const arg = String(value || "").trim();
191
+ if (!arg) return;
192
+ settings = mergeAssistantSettings(settings, { providerArgs: [...settings.providerArgs, arg] });
193
+ saveAssistantSettings(productDir, settings);
194
+ notify();
195
+ },
196
+
197
+ clearProviderArgs() {
198
+ settings = mergeAssistantSettings(settings, { providerArgs: [] });
199
+ saveAssistantSettings(productDir, settings);
200
+ notify();
201
+ },
202
+
203
+ resetSettings() {
204
+ settings = resetAssistantSettings(productDir);
205
+ resolvedProviderName = null;
150
206
  notify();
151
207
  },
152
208
 
@@ -156,9 +212,8 @@ export function createAssistantState({
156
212
  },
157
213
 
158
214
  async submitCurrentComposer() {
159
- const value = composer.trim();
160
- composer = "";
161
- composerCursor = 0;
215
+ const value = composerState.text.trim();
216
+ composerState = createComposerState();
162
217
  notify();
163
218
  if (!value) return;
164
219
  await state.submitInput(value);
@@ -180,7 +235,7 @@ export function createAssistantState({
180
235
  slash,
181
236
  state,
182
237
  productDir,
183
- providerName,
238
+ settings,
184
239
  configs,
185
240
  env,
186
241
  appendMessage,
@@ -197,13 +252,13 @@ export function createAssistantState({
197
252
  }
198
253
 
199
254
  try {
200
- setBusy(true, `Thinking with ${providerName === "auto" ? "provider" : providerName}...`);
255
+ setBusy(true, `Thinking with ${settings.provider === "auto" ? "provider" : settings.provider}...`);
201
256
  const emitted = await runAssistantConversationTurn({
202
257
  productDir,
203
258
  inspectState,
204
259
  transcript: messages.map((entry) => ({ role: entry.role, text: entry.text })),
205
260
  userMessage: trimmed,
206
- provider: providerName,
261
+ settings,
207
262
  env,
208
263
  configs,
209
264
  commandLog,
@@ -211,6 +266,10 @@ export function createAssistantState({
211
266
  activeStatus = status;
212
267
  notify();
213
268
  },
269
+ onResolvedProvider(provider) {
270
+ resolvedProviderName = provider;
271
+ notify();
272
+ },
214
273
  });
215
274
  for (const message of emitted) appendMessage(message);
216
275
  } catch (error) {
@@ -233,11 +292,15 @@ export function createAssistantState({
233
292
  return {
234
293
  context: buildContextSelection(inspectState.getSnapshot()),
235
294
  messages: [...messages],
236
- composer,
237
- composerCursor,
295
+ composer: composerState.text,
296
+ composerCursor: composerState.cursor,
238
297
  notice,
239
298
  busy,
240
- provider: providerName,
299
+ provider: settings.provider,
300
+ resolvedProvider: resolvedProviderName,
301
+ model: settings.model,
302
+ effort: settings.effort,
303
+ providerArgs: [...settings.providerArgs],
241
304
  activeStatus,
242
305
  contextPaths: {
243
306
  contextPath: commandLog.contextPath,
@@ -258,7 +321,7 @@ async function executeSlashCommand({
258
321
  slash,
259
322
  state,
260
323
  productDir,
261
- providerName,
324
+ settings,
262
325
  configs,
263
326
  env,
264
327
  appendMessage,
@@ -280,6 +343,40 @@ async function executeSlashCommand({
280
343
  appendMessage({ role: "assistant", text: `Provider set to ${slash.provider}.` });
281
344
  return;
282
345
  }
346
+ if (slash.type === "model") {
347
+ state.setModel(slash.model);
348
+ appendMessage({ role: "assistant", text: slash.model ? `Model set to ${slash.model}.` : "Model reset to provider default." });
349
+ return;
350
+ }
351
+ if (slash.type === "effort") {
352
+ state.setEffort(slash.effort);
353
+ appendMessage({ role: "assistant", text: slash.effort ? `Effort set to ${slash.effort}.` : "Effort reset to provider default." });
354
+ return;
355
+ }
356
+ if (slash.type === "provider-args-add") {
357
+ state.addProviderArg(slash.value);
358
+ appendMessage({ role: "assistant", text: `Provider arg added: ${slash.value}` });
359
+ return;
360
+ }
361
+ if (slash.type === "provider-args-clear") {
362
+ state.clearProviderArgs();
363
+ appendMessage({ role: "assistant", text: "Provider args cleared." });
364
+ return;
365
+ }
366
+ if (slash.type === "provider-args-list") {
367
+ const args = state.getSnapshot().providerArgs;
368
+ appendMessage({ role: "assistant", text: args.length ? args.map((arg) => `- ${arg}`).join("\n") : "No provider args configured." });
369
+ return;
370
+ }
371
+ if (slash.type === "settings-show") {
372
+ appendMessage({ role: "assistant", text: formatSettings(state.getSnapshot()) });
373
+ return;
374
+ }
375
+ if (slash.type === "settings-reset") {
376
+ state.resetSettings();
377
+ appendMessage({ role: "assistant", text: "Assistant settings reset." });
378
+ return;
379
+ }
283
380
 
284
381
  const result = await executeSlashTool(slash, {
285
382
  productDir,
@@ -292,7 +389,7 @@ async function executeSlashCommand({
292
389
  state.setNotice(event.message);
293
390
  }
294
391
  },
295
- provider: providerName,
392
+ provider: settings.provider,
296
393
  });
297
394
  appendMessage({
298
395
  role: "tool",
@@ -303,6 +400,17 @@ async function executeSlashCommand({
303
400
  });
304
401
  }
305
402
 
403
+ function formatSettings(snapshot) {
404
+ const rows = [
405
+ ["Provider", snapshot.provider || "auto"],
406
+ ["Resolved", snapshot.resolvedProvider || "not resolved yet"],
407
+ ["Model", snapshot.model || "provider default"],
408
+ ["Effort", snapshot.effort || "provider default"],
409
+ ["Provider args", snapshot.providerArgs?.length ? snapshot.providerArgs.join(" ") : "none"],
410
+ ];
411
+ return rows.map(([label, value]) => `${label}: ${value}`).join("\n");
412
+ }
413
+
306
414
  async function executeSlashTool(slash, context) {
307
415
  switch (slash.type) {
308
416
  case "inspect":
@@ -13,7 +13,20 @@ export default class AssistantCommand extends Command {
13
13
  provider: Flags.string({
14
14
  description: "Assistant provider",
15
15
  options: ["auto", "claude", "codex"],
16
- default: "auto",
16
+ }),
17
+ model: Flags.string({
18
+ description: "Assistant model",
19
+ }),
20
+ effort: Flags.string({
21
+ description: "Assistant effort",
22
+ options: ["low", "medium", "high", "xhigh", "max"],
23
+ }),
24
+ "provider-arg": Flags.string({
25
+ description: "Advanced provider-specific argument, repeatable",
26
+ multiple: true,
27
+ }),
28
+ "reset-assistant-settings": Flags.boolean({
29
+ description: "Reset persisted assistant provider/model settings before starting",
17
30
  }),
18
31
  file: Flags.string({
19
32
  description: "Initial file selection",
@@ -47,6 +60,10 @@ export default class AssistantCommand extends Command {
47
60
  const assistantState = createAssistantState({
48
61
  productDir,
49
62
  provider: flags.provider,
63
+ model: flags.model,
64
+ effort: flags.effort,
65
+ providerArgs: flags["provider-arg"],
66
+ resetSettings: flags["reset-assistant-settings"],
50
67
  configs: allConfigs,
51
68
  env: process.env,
52
69
  });
@@ -83,6 +100,10 @@ export default class AssistantCommand extends Command {
83
100
  return runInteractiveAssistant({
84
101
  productDir,
85
102
  provider: flags.provider,
103
+ model: flags.model,
104
+ effort: flags.effort,
105
+ providerArgs: flags["provider-arg"],
106
+ resetSettings: flags["reset-assistant-settings"],
86
107
  file: flags.file || null,
87
108
  service: flags.service || null,
88
109
  prompt: flags.prompt || null,
@@ -35,6 +35,9 @@ export function normalizeCliArgs(argv) {
35
35
  "--tail",
36
36
  "--log-tail",
37
37
  "--provider",
38
+ "--model",
39
+ "--effort",
40
+ "--provider-arg",
38
41
  "--message",
39
42
  "--prompt",
40
43
  ]);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
4
4
  "description": "Browser bridge helpers for testkit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc -p tsconfig.json --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@elench/testkit-protocol": "0.1.91"
25
+ "@elench/testkit-protocol": "0.1.93"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/ts-analysis",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.91",
4
- "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
3
+ "version": "0.1.93",
4
+ "description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
5
5
  "type": "module",
6
6
  "workspaces": [
7
7
  "packages/*"
@@ -82,10 +82,10 @@
82
82
  },
83
83
  "dependencies": {
84
84
  "@babel/code-frame": "^7.29.0",
85
- "@elench/next-analysis": "0.1.91",
86
- "@elench/testkit-bridge": "0.1.91",
87
- "@elench/testkit-protocol": "0.1.91",
88
- "@elench/ts-analysis": "0.1.91",
85
+ "@elench/next-analysis": "0.1.93",
86
+ "@elench/testkit-bridge": "0.1.93",
87
+ "@elench/testkit-protocol": "0.1.93",
88
+ "@elench/ts-analysis": "0.1.93",
89
89
  "@oclif/core": "^4.10.6",
90
90
  "esbuild": "^0.25.11",
91
91
  "execa": "^9.5.0",