@quatcode/agent 0.2.8 → 0.2.10

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/dist/tui.js CHANGED
@@ -12,10 +12,11 @@ import { TurnController, fmtCost, fmtDuration, fmtTokens, toolArg, toolResultBlo
12
12
  import { renderBanner } from "./logo.js";
13
13
  import { renderMarkdown, wrapSegments } from "./markdown.js";
14
14
  import { ADD_PROVIDER, allProviders, buildProvider, defaultModelFor, effortsForModel, hasKey, keyEnvFor, probeProviderModels, providerEnvKey, providerHasModel, providerMeta, providerSlug, registerCustomProvider, removeCustomProvider, resolveProviderForModel, } from "./models.js";
15
- import { activeThemeId, bg, bold, buildColorRemap, clipAnsi, fg, getThemes, glyphs, padEndTo, palette, remapColors, setTheme, stringWidth, stripAnsi, tone, toolGlyph, } from "./theme.js";
15
+ import { ASCII_MODE, activeThemeId, bg, bold, buildColorRemap, clipAnsi, fg, getThemes, glyphs, mix, padEndTo, palette, pulse, remapColors, setTheme, shimmer, stringWidth, stripAnsi, tone, toolGlyph, } from "./theme.js";
16
16
  import { listSessions, listSessionsForDir, loadConfig, makeSessionId, saveConfig, saveSession, deleteSession } from "./store.js";
17
- import { configFile, conversationKey, deleteConversation, displayTitle, latestPerConversation, messagesForStorage, sameDir, sortConversations, updateConversation } from "./store.js";
17
+ import { appendAudit, auditInput, configFile, conversationKey, deleteConversation, displayTitle, latestPerConversation, messagesForStorage, sameDir, sortConversations, updateConversation } from "./store.js";
18
18
  import { MCP_USAGE, isMcpUrl, mcpInstructionsSection, parseMcpAdd } from "./mcp.js";
19
+ import { AUTO, PERSONA_SETTINGS, personaSection, personaSummary, } from "./persona.js";
19
20
  import { copyToClipboard, readClipboardImage, readClipboardText, } from "./clipboard.js";
20
21
  import { createLoadSkillTool, fetchSkillFromUrl, importSkillFile, listSkills, parseSkill, removeSkill, saveSkill, skillsRoot, skillsSystemSection, } from "./skills.js";
21
22
  import { VERSION } from "./version.js";
@@ -34,6 +35,7 @@ const COMMAND_INFO = [
34
35
  { name: "/timeline", desc: "jump to a sent message" },
35
36
  { name: "/undo", desc: "take back the last message" },
36
37
  { name: "/verbose", desc: "toggle full tool output" },
38
+ { name: "/settings", desc: "tune the agent's persona: character, tone, verbosity…" },
37
39
  { name: "/skills", args: "[add|remove|list]", desc: "manage agent skills" },
38
40
  { name: "/mcp", args: "[list|add|remove]", desc: "manage MCP servers" },
39
41
  { name: "/theme", args: "[id]", desc: "change the color theme" },
@@ -75,6 +77,7 @@ class TuiApp {
75
77
  model = "";
76
78
  effort = null;
77
79
  config = {};
80
+ persona = {};
78
81
  summary = null;
79
82
  skills = [];
80
83
  system = "";
@@ -185,6 +188,7 @@ class TuiApp {
185
188
  process.exit(143);
186
189
  });
187
190
  this.config = await loadConfig().catch(() => ({}));
191
+ this.persona = this.config.persona ? { ...this.config.persona } : {};
188
192
  for (const [name, value] of Object.entries(this.config.keys ?? {})) {
189
193
  if (value && process.env[name] === undefined)
190
194
  process.env[name] = value;
@@ -751,9 +755,15 @@ class TuiApp {
751
755
  try {
752
756
  const response = await provider.complete({
753
757
  model: this.model,
754
- system: `${this.system ? `${this.system}\n\n` : ""}# side channel\nThe user is asking a quick side question while the main task runs. Answer briefly and directly. Do not use tools, do not change the main task, and do not ask to run anything.`,
758
+ system: [
759
+ this.system,
760
+ personaSection(this.persona),
761
+ "# side channel\nThe user is asking a quick side question while the main task runs. Answer briefly and directly. Do not use tools, do not change the main task, and do not ask to run anything.",
762
+ ]
763
+ .filter(Boolean)
764
+ .join("\n\n"),
755
765
  messages: [
756
- ...this.session.getMessages(),
766
+ ...sideChannelMessages(this.session.getMessages()),
757
767
  { role: "user", content: [{ type: "text", text }] },
758
768
  ],
759
769
  maxTokens: 2048,
@@ -998,6 +1008,10 @@ class TuiApp {
998
1008
  return;
999
1009
  }
1000
1010
  if (this.mode === "picker" && this.picker) {
1011
+ if (this.picker.backAt(row - this.lastHeaderRows)) {
1012
+ this.closePicker(null);
1013
+ return;
1014
+ }
1001
1015
  const value = this.picker.pickAt(row - this.lastHeaderRows);
1002
1016
  if (value !== null)
1003
1017
  this.closePicker(value);
@@ -1351,8 +1365,9 @@ class TuiApp {
1351
1365
  return [rule, "", bottom];
1352
1366
  }
1353
1367
  statusLine() {
1354
- const spin = tone.accent(glyphs.spinner[this.spinnerFrame % glyphs.spinner.length] ?? "-");
1355
- const elapsed = ((performance.now() - this.statusStartedAt) / 1000).toFixed(1);
1368
+ const now = performance.now();
1369
+ const elapsedMs = now - this.statusStartedAt;
1370
+ const elapsed = (elapsedMs / 1000).toFixed(1);
1356
1371
  const meta = [
1357
1372
  this.status?.meta,
1358
1373
  `${elapsed}s`,
@@ -1360,7 +1375,19 @@ class TuiApp {
1360
1375
  ]
1361
1376
  .filter(Boolean)
1362
1377
  .join(" · ");
1363
- return ` ${spin} ${this.status?.label ?? ""} ${tone.mist(meta)}`;
1378
+ const frames = glyphs.spinner;
1379
+ const glyph = frames[this.spinnerFrame % frames.length] ?? glyphs.diamond;
1380
+ const icon = fg(mix(palette.accent, palette.bone, pulse(now, 900)), glyph);
1381
+ const base = mix(palette.silver, palette.mist, 0.35);
1382
+ let label;
1383
+ if (this.status?.phase === "tool") {
1384
+ const name = this.status.label;
1385
+ label = `${tone.accent(toolGlyph(name))} ${shimmer(name, now / 55, { base, highlight: palette.bone })}`;
1386
+ }
1387
+ else {
1388
+ label = shimmer(this.status?.label ?? "", now / 55, { base, highlight: palette.bone });
1389
+ }
1390
+ return ` ${icon} ${label} ${tone.mist(meta)}`;
1364
1391
  }
1365
1392
  visibleTranscript(viewport) {
1366
1393
  let contentEnd = this.transcript.length;
@@ -1423,7 +1450,7 @@ class TuiApp {
1423
1450
  this.spinnerTimer = setInterval(() => {
1424
1451
  this.spinnerFrame += 1;
1425
1452
  this.scheduleRender();
1426
- }, 90);
1453
+ }, 75);
1427
1454
  if (typeof this.spinnerTimer.unref === "function")
1428
1455
  this.spinnerTimer.unref();
1429
1456
  }
@@ -1569,7 +1596,7 @@ class TuiApp {
1569
1596
  this.agentMode = this.agentMode === "build" ? "plan" : "build";
1570
1597
  this.rebuildTools();
1571
1598
  this.append([
1572
- ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// MODE → ${this.agentMode.toUpperCase()}`)}${this.agentMode === "plan" ? ` ${tone.amber("no edits · run_command allowed (with approval)")}` : ""}`,
1599
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// MODE → ${this.agentMode.toUpperCase()}`)}${this.agentMode === "plan" ? ` ${tone.amber("· no edits")}` : ""}`,
1573
1600
  ]);
1574
1601
  }
1575
1602
  ensureProvider() {
@@ -1955,21 +1982,50 @@ class TuiApp {
1955
1982
  this.append([
1956
1983
  ` ${tone.amber(glyphs.diamondOpen)} ${tone.mist(`// ${req.tool} blocked in PLAN mode (Tab switches to BUILD)`)}`,
1957
1984
  ]);
1985
+ this.audit(req, "denied", "plan");
1958
1986
  return { allowed: false };
1959
1987
  }
1960
1988
  if (this.agentMode === "plan" && READ_ONLY_TOOLS.has(req.tool))
1961
1989
  return { allowed: true };
1962
- if (this.gateMode === "always")
1990
+ const analysis = req.tool === "run_command" ? this.commandAnalysis(req) : null;
1991
+ const dangerous = analysis?.dangerous === true;
1992
+ if (this.opts.allowAll) {
1993
+ this.audit(req, "allowed", "allow-all", analysis);
1963
1994
  return { allowed: true };
1964
- const choice = await this.permissionCard(req);
1965
- if (choice === "always") {
1966
- this.gateMode = "always";
1995
+ }
1996
+ // "always" never covers dangerous commands — those are confirmed each time.
1997
+ if (this.gateMode === "always" && !dangerous) {
1998
+ this.audit(req, "allowed", "always", analysis);
1967
1999
  return { allowed: true };
1968
2000
  }
1969
- return { allowed: choice === "allow" };
2001
+ const choice = await this.permissionCard(req);
2002
+ const granted = choice === "allow" || choice === "always";
2003
+ if (choice === "always" && !dangerous)
2004
+ this.gateMode = "always";
2005
+ this.audit(req, granted ? "allowed" : "denied", choice, analysis);
2006
+ return { allowed: granted };
1970
2007
  },
1971
2008
  };
1972
2009
  }
2010
+ commandAnalysis(req) {
2011
+ const command = req.input && typeof req.input === "object" && "command" in req.input
2012
+ ? String(req.input.command)
2013
+ : "";
2014
+ return command ? analyzeCommand(command) : null;
2015
+ }
2016
+ audit(req, decision, via, analysis) {
2017
+ void appendAudit({
2018
+ ts: new Date().toISOString(),
2019
+ dir: this.dir,
2020
+ tool: req.tool,
2021
+ decision,
2022
+ via,
2023
+ danger: analysis
2024
+ ? { dangerous: analysis.dangerous, sensitive: analysis.sensitive, reasons: analysis.reasons }
2025
+ : undefined,
2026
+ input: auditInput(req.input),
2027
+ }).catch(() => undefined);
2028
+ }
1973
2029
  async promptForKey(keyEnv) {
1974
2030
  this.append([` ${tone.mist(`// ${keyEnv} IS NOT SET`)}`]);
1975
2031
  const result = await this.readLine({
@@ -2017,6 +2073,9 @@ class TuiApp {
2017
2073
  const mcpSection = mcpInstructionsSection(this.mcp.instructions());
2018
2074
  if (mcpSection)
2019
2075
  system = system ? `${system}\n\n${mcpSection}` : mcpSection;
2076
+ const persona = personaSection(this.persona);
2077
+ if (persona)
2078
+ system = system ? `${system}\n\n${persona}` : persona;
2020
2079
  if (this.agentMode === "plan") {
2021
2080
  system = `${system ? `${system}\n\n` : ""}# mode\nYou are in PLAN mode: analyze the codebase and propose a plan. You may run read-only commands with run_command to inspect the project (each command is approved by the user), but never modify files, install packages or run mutating commands. When the plan is ready, present it and tell the user to press Tab to switch to BUILD mode.`;
2022
2081
  }
@@ -2186,6 +2245,9 @@ class TuiApp {
2186
2245
  ` ${tone.mist(`// VERBOSE ${this.verbose ? "ON — FULL TOOL OUTPUT" : "OFF"}`)}`,
2187
2246
  ]);
2188
2247
  return false;
2248
+ case "settings":
2249
+ await this.openSettings();
2250
+ return false;
2189
2251
  case "theme":
2190
2252
  await this.changeTheme(arg);
2191
2253
  return false;
@@ -2206,6 +2268,45 @@ class TuiApp {
2206
2268
  return false;
2207
2269
  }
2208
2270
  }
2271
+ async openSettings() {
2272
+ const before = JSON.stringify(this.persona);
2273
+ for (;;) {
2274
+ const entries = PERSONA_SETTINGS.map((setting) => ({
2275
+ kind: "item",
2276
+ value: setting.id,
2277
+ label: `${ASCII_MODE ? setting.asciiIcon : setting.icon} ${setting.label}`,
2278
+ hint: this.persona[setting.id] ?? AUTO,
2279
+ }));
2280
+ const id = await this.openPicker(new Picker({ title: "settings", entries, filterable: false }));
2281
+ if (!id)
2282
+ break;
2283
+ const setting = PERSONA_SETTINGS.find((entry) => entry.id === id);
2284
+ if (!setting)
2285
+ break;
2286
+ const current = this.persona[setting.id] ?? AUTO;
2287
+ const valueEntries = setting.options.map((option) => ({
2288
+ kind: "item",
2289
+ value: option.value,
2290
+ label: option.value === current ? `${glyphs.check} ${option.label}` : ` ${option.label}`,
2291
+ hint: option.description,
2292
+ }));
2293
+ const value = await this.openPicker(new Picker({ title: setting.label, entries: valueEntries, filterable: false }));
2294
+ if (value === null)
2295
+ continue;
2296
+ if (value === AUTO)
2297
+ delete this.persona[setting.id];
2298
+ else
2299
+ this.persona[setting.id] = value;
2300
+ }
2301
+ if (JSON.stringify(this.persona) !== before) {
2302
+ this.config.persona = this.persona;
2303
+ void saveConfig({ ...this.config }).catch(() => undefined);
2304
+ this.append([
2305
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SETTINGS → ${personaSummary(this.persona)}`)}`,
2306
+ "",
2307
+ ]);
2308
+ }
2309
+ }
2209
2310
  async changeTheme(arg) {
2210
2311
  const current = activeThemeId();
2211
2312
  if (arg) {
@@ -2947,6 +3048,7 @@ class TuiApp {
2947
3048
  ["mouse drag", "select text · ctrl+c copies"],
2948
3049
  ["click in input", "place the cursor"],
2949
3050
  ["permission card", "← → select · enter confirm · mouse click"],
3051
+ ["pickers", "↑↓ move · enter select · ← or esc back · click rows and the ← back button"],
2950
3052
  ["sessions picker", "ctrl+r rename · ctrl+p pin · ctrl+d delete"],
2951
3053
  ["↑ ↓", "input history — previous / next message"],
2952
3054
  ["wheel", "scroll the conversation"],
@@ -3099,6 +3201,29 @@ const IMAGE_EXTENSIONS = {
3099
3201
  ".bmp": "image/bmp",
3100
3202
  };
3101
3203
  const IMAGE_MARKER_RE = /\[Image (\d+)\]/g;
3204
+ /**
3205
+ * A side-channel (/btw) request can run while a tool call is still in flight,
3206
+ * so the transcript may end with an assistant tool_call that has no tool result
3207
+ * yet. Providers reject such a sequence (400), so drop unanswered tool_calls.
3208
+ */
3209
+ export function sideChannelMessages(messages) {
3210
+ const answered = new Set();
3211
+ for (const message of messages) {
3212
+ if (message.role === "tool")
3213
+ answered.add(message.toolCallId);
3214
+ }
3215
+ const out = [];
3216
+ for (const message of messages) {
3217
+ if (message.role !== "assistant" || !message.content.some((part) => part.type === "tool_call")) {
3218
+ out.push(message);
3219
+ continue;
3220
+ }
3221
+ const content = message.content.filter((part) => part.type !== "tool_call" || answered.has(part.id));
3222
+ if (content.length > 0)
3223
+ out.push({ ...message, content });
3224
+ }
3225
+ return out;
3226
+ }
3102
3227
  export function nextImageIndex(text) {
3103
3228
  const used = new Set();
3104
3229
  for (const match of text.matchAll(IMAGE_MARKER_RE)) {