sakuracode 0.1.0 → 0.1.1

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
@@ -135,6 +135,7 @@ sakuracode providers # providers and whether their key is set (ne
135
135
  |---|---|---|
136
136
  | Enter | send | **steer**: delivered before the agent's next model request |
137
137
  | Tab | complete a slash command | **queue**: handled when the agent would stop |
138
+ | Shift-Tab | switch the review mode: auto, strict, read-only (`full-access` only with `/permissions`) | applied when the run ends |
138
139
  | Esc | close the popup | **cancel** the run |
139
140
  | Ctrl-C | clear the prompt; twice on an empty prompt leaves | cancel the run |
140
141
  | Ctrl-D | leave (empty prompt) | |
package/README.zh-CN.md CHANGED
@@ -135,6 +135,7 @@ sakuracode providers # 服务商以及它们的密钥是否已设
135
135
  |---|---|---|
136
136
  | Enter | 发送 | **引导**:在 Agent 的下一个模型请求之前送达 |
137
137
  | Tab | 补全斜杠命令 | **排队**:Agent 要停下时处理 |
138
+ | Shift-Tab | 切换审查模式:auto、strict、read-only(`full-access` 只能用 `/permissions` 设置) | 运行结束后生效 |
138
139
  | Esc | 关闭弹出列表 | **取消**这次运行 |
139
140
  | Ctrl-C | 清空提示;在空提示上按两次退出 | 取消这次运行 |
140
141
  | Ctrl-D | 退出(提示为空时) | |
@@ -0,0 +1,44 @@
1
+ function messageCharacters(message) {
2
+ let characters = 0;
3
+ for (const part of message.content) {
4
+ if (part.type === "text")
5
+ characters += part.text.length;
6
+ else if (part.type === "tool_call")
7
+ characters += part.name.length + JSON.stringify(part.args ?? "").length;
8
+ else if (part.type === "image")
9
+ characters += 4000;
10
+ else
11
+ characters += JSON.stringify(part.data ?? "").length;
12
+ }
13
+ return characters;
14
+ }
15
+ /** Tokens of the log from the compaction on: the summary, the kept messages and everything after. */
16
+ export function estimateAfterCompaction(branch) {
17
+ const compaction = branch.findLastIndex((entry) => entry.type === "compaction");
18
+ const entry = branch[compaction];
19
+ if (!entry || entry.type !== "compaction")
20
+ return undefined;
21
+ const kept = branch.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
22
+ const from = kept === -1 ? compaction + 1 : kept;
23
+ let characters = entry.summary.length;
24
+ for (let index = from; index < branch.length; index++) {
25
+ const candidate = branch[index];
26
+ if (candidate?.type === "message")
27
+ characters += messageCharacters(candidate.message);
28
+ else if (candidate?.type === "custom_message")
29
+ characters += candidate.content.reduce((sum, part) => sum + (part.type === "text" ? part.text.length : 0), 0);
30
+ }
31
+ return Math.ceil(characters / 4);
32
+ }
33
+ export async function measureContext(source) {
34
+ const status = await source.context();
35
+ const branch = await source.log.branch();
36
+ const compaction = branch.findLastIndex((entry) => entry.type === "compaction");
37
+ if (compaction === -1)
38
+ return { tokens: status.tokens, window: status.window, estimated: false };
39
+ const replied = branch.slice(compaction + 1).some((entry) => entry.type === "message" && entry.message.role === "assistant" && entry.message.usage !== undefined);
40
+ if (replied)
41
+ return { tokens: status.tokens, window: status.window, estimated: false };
42
+ const tokens = estimateAfterCompaction(branch) ?? status.tokens;
43
+ return { tokens, window: status.window, estimated: true };
44
+ }
@@ -7,6 +7,15 @@ export const PERMISSION_PRESETS = {
7
7
  strict: { name: "strict", summary: "reviewed; three refusals in a row pause the run", review: true, tools: undefined, breakerConsecutive: 3 },
8
8
  "full-access": { name: "full-access", summary: "nothing is reviewed", review: false, tools: undefined, breakerConsecutive: undefined },
9
9
  };
10
+ /**
11
+ * What Shift+Tab switches to: auto, then strict, then read-only, then auto again. `full-access` is never reached by the key
12
+ * (it needs the explicit `/permissions full-access`); from it the key goes back to auto.
13
+ */
14
+ export function nextPermission(current) {
15
+ const cycle = ["auto", "strict", "read-only"];
16
+ const index = cycle.indexOf(current);
17
+ return index === -1 ? "auto" : cycle[(index + 1) % cycle.length];
18
+ }
10
19
  export function isPermissionName(value) {
11
20
  return PERMISSION_NAMES.includes(value);
12
21
  }
@@ -39,7 +39,7 @@ export function statusReport(input) {
39
39
  ["Model", `${input.model} via ${input.provider}${input.thinking ? `, thinking ${input.thinking}` : ""}`],
40
40
  ["Permissions", `${input.permissions}: ${PERMISSION_PRESETS[input.permissions].summary}`],
41
41
  ["Redaction", input.redaction ? "on" : "off"],
42
- ["Context", `${number(input.context.tokens)} of ${number(input.context.window)} tokens used, ${free}% left${input.context.compactAt ? `, compacts at ${number(input.context.compactAt)}` : ""}`],
42
+ ["Context", `${input.contextEstimated ? "about " : ""}${number(input.context.tokens)} of ${number(input.context.window)} tokens used${input.contextEstimated ? " (estimate until the next reply)" : ""}, ${input.contextEstimated ? "~" : ""}${free}% left${input.context.compactAt ? `, compacts at ${number(input.context.compactAt)}` : ""}`],
43
43
  ...usageRows(input),
44
44
  ["Goal", input.goal ? `${input.goal.status}: ${input.goal.objective}` : "none"],
45
45
  ["Idle compaction", input.idleCompaction],
@@ -3,13 +3,14 @@
3
3
  // through the Transcript (what to show) and the Surface (what only a screen can do).
4
4
  import { openAgentSession } from "./agent-session.js";
5
5
  import { expandMentions } from "./mentions.js";
6
- import { isPermissionName } from "./permissions.js";
6
+ import { isPermissionName, nextPermission } from "./permissions.js";
7
7
  import { allProviders, chooseProvider, listProviders } from "./providers.js";
8
8
  import { mcpReport, medianReplyHitRate, modelsReport, permissionsReport, statusReport } from "./reports.js";
9
9
  import { createRunDriver } from "./run-driver.js";
10
10
  import { sessionLabel, titleFromPrompt } from "./session-store.js";
11
11
  import { runShell } from "./shell.js";
12
12
  import { findCommand, helpMarkdown, parseSlash } from "./slash-commands.js";
13
+ import { measureContext } from "./context-estimate.js";
13
14
  import { contextLeftPercent } from "./status-line.js";
14
15
  import { excerpt, replayLog } from "./transcript.js";
15
16
  import { imageMediaType } from "./images.js";
@@ -26,6 +27,13 @@ export function createController(deps) {
26
27
  let session;
27
28
  let meta;
28
29
  let contextPercent;
30
+ // a mode asked for during a run; the agent is not reopened while it works
31
+ let pendingPermissions;
32
+ let contextEstimated = false;
33
+ let contextEstimatedShown = false;
34
+ let contextWindow = 0;
35
+ // a newer measurement wins over an older one that finishes later
36
+ let contextVersion = 0;
29
37
  const firstImages = [...(deps.images ?? [])];
30
38
  let choice = {
31
39
  provider: deps.clients ? "scripted" : chooseProvider(deps.settings, deps.env),
@@ -35,6 +43,18 @@ export function createController(deps) {
35
43
  thinking: deps.settings.thinking,
36
44
  };
37
45
  driver.onChange(() => surface.changed());
46
+ // The percentage follows the run: each finished reply carries the provider-reported usage, and a compaction changes the size at once.
47
+ driver.onEvent((event) => {
48
+ if (event.type === "message_end" && event.message.role === "assistant" && event.message.usage && contextWindow > 0) {
49
+ const { input, output, cacheRead, cacheWrite } = event.message.usage;
50
+ contextVersion++;
51
+ contextEstimated = false;
52
+ setContextPercent(contextLeftPercent(contextWindow - (input + output + cacheRead + cacheWrite), contextWindow));
53
+ }
54
+ else if (event.type === "compaction") {
55
+ void refreshContext().catch(() => undefined);
56
+ }
57
+ });
38
58
  const notice = (level, text) => transcript.add({ kind: "notice", level, text });
39
59
  const requireSession = () => {
40
60
  if (!session)
@@ -105,11 +125,29 @@ export function createController(deps) {
105
125
  replayLog(transcript, entries);
106
126
  notice("info", `Resumed ${sessionLabel(meta ?? target)} (${target.id.slice(0, 8)}).`);
107
127
  }
128
+ async function statusContext(active) {
129
+ const status = await active.agent.context();
130
+ const measured = await measureContext(active.agent);
131
+ return { context: { ...status, tokens: measured.tokens, free: Math.max(0, measured.window - measured.tokens) }, contextEstimated: measured.estimated };
132
+ }
133
+ function setContextPercent(next) {
134
+ const flagChanged = contextEstimatedShown !== contextEstimated;
135
+ contextEstimatedShown = contextEstimated;
136
+ if (next === contextPercent && !flagChanged)
137
+ return;
138
+ contextPercent = next;
139
+ surface.changed();
140
+ }
108
141
  async function refreshContext() {
109
142
  if (!session)
110
143
  return;
111
- const status = await session.agent.context();
112
- contextPercent = contextLeftPercent(status.free, status.window);
144
+ const version = ++contextVersion;
145
+ const measured = await measureContext(session.agent);
146
+ if (version !== contextVersion)
147
+ return;
148
+ contextWindow = measured.window;
149
+ contextEstimated = measured.estimated;
150
+ setContextPercent(contextLeftPercent(measured.window - measured.tokens, measured.window));
113
151
  }
114
152
  // ---------- running ----------
115
153
  async function runInput(input, options) {
@@ -127,8 +165,18 @@ export function createController(deps) {
127
165
  }
128
166
  if (meta)
129
167
  meta = await store.update(meta.id, {});
168
+ await applyPendingPermissions();
130
169
  return outcome.result;
131
170
  }
171
+ async function applyPendingPermissions() {
172
+ const wanted = pendingPermissions;
173
+ pendingPermissions = undefined;
174
+ if (wanted === undefined || wanted === choice.permissions)
175
+ return surface.changed();
176
+ if (await reopen({ permissions: wanted }))
177
+ notice("info", `Permissions: ${wanted} (Shift+Tab to change)`);
178
+ surface.changed();
179
+ }
132
180
  function reportEnd(result) {
133
181
  if (result.reason === "error")
134
182
  notice("error", `The run failed: ${result.error ?? "unknown error"}`);
@@ -179,7 +227,7 @@ export function createController(deps) {
179
227
  thinking: active.thinking,
180
228
  permissions: active.permissions,
181
229
  redaction: active.redaction,
182
- context: await active.agent.context(),
230
+ ...(await statusContext(active)),
183
231
  usage: await active.agent.usage(),
184
232
  medianHitRate: medianReplyHitRate(await active.agent.log.branch()),
185
233
  goal: await active.agent.goal(),
@@ -201,6 +249,7 @@ export function createController(deps) {
201
249
  return host.print(permissionsReport(choice.permissions, choice.redaction));
202
250
  if (!isPermissionName(name))
203
251
  return notice("error", `Unknown preset "${name}". Use auto, read-only, strict or full-access.`);
252
+ pendingPermissions = undefined;
204
253
  if (await reopen({ permissions: name }))
205
254
  notice("info", `Permissions: ${name}.`);
206
255
  },
@@ -379,10 +428,22 @@ export function createController(deps) {
379
428
  transcript.muteCompaction(false);
380
429
  stop();
381
430
  }
382
- const after = (await requireSession().agent.context()).tokens;
431
+ const after = (await measureContext(requireSession().agent)).tokens;
383
432
  notice("info", `compacted after ${idleMinutes} min idle, ${thousands(before)} to ${thousands(after)} tokens`);
384
433
  return true;
385
434
  },
435
+ async cyclePermissions() {
436
+ const next = nextPermission(pendingPermissions ?? choice.permissions);
437
+ if (driver.working || launching) {
438
+ pendingPermissions = next === choice.permissions ? undefined : next;
439
+ return surface.changed();
440
+ }
441
+ if (!session)
442
+ return;
443
+ if (await reopen({ permissions: next }))
444
+ notice("info", `Permissions: ${next} (Shift+Tab to change)`);
445
+ surface.changed();
446
+ },
386
447
  statusInfo: () => ({
387
448
  provider: choice.provider,
388
449
  model: choice.model ?? "",
@@ -390,7 +451,9 @@ export function createController(deps) {
390
451
  cwd: deps.cwd,
391
452
  home: deps.home,
392
453
  contextLeftPercent: contextPercent,
454
+ contextEstimated: contextEstimatedShown,
393
455
  permissions: choice.permissions,
456
+ pendingPermissions,
394
457
  redaction: choice.redaction,
395
458
  working: driver.working,
396
459
  queued: driver.pending().length,
@@ -79,5 +79,5 @@ export function complete(input) {
79
79
  }
80
80
  export function helpMarkdown() {
81
81
  const rows = COMMANDS.map((command) => `| \`${command.usage}\` | ${command.summary} |`);
82
- return ["| Command | What it does |", "|---|---|", ...rows, "", "While the agent works: **Enter** steers it, **Tab** queues a message, **Esc** cancels. `!cmd` runs a shell command yourself."].join("\n");
82
+ return ["| Command | What it does |", "|---|---|", ...rows, "", "While the agent works: **Enter** steers it, **Tab** queues a message, **Esc** cancels. **Shift+Tab** cycles the review mode (auto, strict, read-only; `/permissions full-access` is explicit). `!cmd` runs a shell command yourself."].join("\n");
83
83
  }
@@ -11,12 +11,21 @@ export function shortenPath(path, home, width) {
11
11
  export function contextLeftPercent(free, window) {
12
12
  return window <= 0 ? 0 : Math.max(0, Math.min(100, Math.round((free / window) * 100)));
13
13
  }
14
+ function contextText(info, pal) {
15
+ const text = `${info.contextEstimated ? "~" : ""}${info.contextLeftPercent}%`;
16
+ return (info.contextLeftPercent ?? 100) < 15 ? pal.red(text) : text;
17
+ }
18
+ function permissionsPiece(info, pal) {
19
+ const mode = info.pendingPermissions ?? info.permissions;
20
+ const paint = mode === "auto" ? pal.dim : mode === "full-access" ? pal.red : pal.yellow;
21
+ return info.pendingPermissions === undefined ? paint(mode) : paint(mode) + pal.dim(" (next turn)");
22
+ }
14
23
  export function formatStatusLine(info, width, color) {
15
24
  const pal = createPalette(color);
16
25
  const pieces = [
17
26
  pal.bold(info.model) + (info.thinking ? pal.dim(` ${info.thinking}`) : ""),
18
- info.contextLeftPercent === undefined ? "" : `${info.contextLeftPercent < 15 ? pal.red(`${info.contextLeftPercent}%`) : `${info.contextLeftPercent}%`} context left`,
19
- info.permissions === "auto" ? "" : pal.yellow(info.permissions),
27
+ info.contextLeftPercent === undefined ? "" : `${contextText(info, pal)} context left`,
28
+ permissionsPiece(info, pal),
20
29
  info.redaction ? pal.magenta("redaction") : "",
21
30
  info.queued > 0 ? pal.cyan(`${info.queued} queued`) : "",
22
31
  shortenPath(info.cwd, info.home, Math.max(12, Math.floor(width / 3))),
@@ -163,6 +163,12 @@ export async function runTui(options) {
163
163
  // a dropped or pasted image file becomes an @mention, which attaches it
164
164
  const dropped = key.name === "paste" ? pastedImagePath(key.text ?? "", options.controller.cwd) : undefined;
165
165
  const action = editor.handle(dropped ? { name: "paste", text: `@${dropped} ` } : key);
166
+ if (action === "shift-tab") {
167
+ // the command popup uses Tab; Shift+Tab never changes the mode while it is open
168
+ if (commands.length === 0)
169
+ void controller.cyclePermissions().catch((error) => showHint(error.message));
170
+ return;
171
+ }
166
172
  if (action !== "none")
167
173
  popupIndex = action === "tab" ? popupIndex : 0;
168
174
  if (action === "submit")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sakuracode",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "sakuracode: an open-source terminal coding agent (command-line AI coding assistant) with automatic review of every tool call. Works with DeepSeek, OpenCode Go and any OpenAI-compatible endpoint.",