@yanzc/claude-pro 0.5.5 → 0.5.7
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 +3 -1
- package/package.json +1 -1
- package/src/agent.js +2 -0
- package/src/api.js +23 -7
- package/src/cache.js +17 -0
- package/src/commands.js +2 -1
- package/src/pricing.js +59 -0
- package/src/print.js +9 -0
- package/src/restart.js +20 -3
- package/src/session.js +11 -0
- package/src/ui/ansi.js +2 -0
- package/src/ui/render.js +8 -3
- package/src/ui/repl.js +146 -11
- package/src/ui/screen.js +165 -4
package/README.md
CHANGED
|
@@ -161,7 +161,8 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
|
|
|
161
161
|
## 终端标题、页脚与窗口缩放
|
|
162
162
|
|
|
163
163
|
- **终端标题**:标签/窗口标题显示 `✳ <会话标题> | <目录> · <模型> · <effort>`(会话标题是**当前话题**:每次发消息后,后台让小模型(haiku)判断是否换了话题,换了就生成一个简短标题,和官方客户端一样;"继续"之类的消息不会改标题。话题记在会话文件里,`/resume`、重启后仍在,`/resume` 列表也显示它。旧会话没有话题时,恢复时按最近一条提问生成。还没生成时显示第一条提问。最多 40 字,保证模型和 effort 不被窄标签截掉。`CLAUDE_PRO_DISABLE_TOPIC_TITLE=1` 关闭,省掉每条消息一次的小请求)。忙碌时前缀在 `⠂`/`⠐` 之间闪动,限流暂停时变 `⏸`,退出时恢复进入前的标题。`/model`、`/effort`、`/clear`、`/resume` 后自动更新。**VS Code**(1.140 起)只对它认得的 AI 命令行(claude/codex/copilot/gemini)把程序设置的标题显示在标签上(`terminal.integrated.tabs.allowAgentCliTitle`,默认开),其余一律显示进程名 `node`;它在 Windows 上靠 `node.exe` 命令行里的 `\claude-code\` 路径段识别 Claude Code,所以监督进程用 `bin\claude-code\..\claude-pro.js` 启动会话(目录不必存在,Node 自己化简 `..`)。不经过监督进程的启动方式(`CLAUDE_PRO_NO_SUPERVISOR=1`)或 Linux/macOS 上,要在 VS Code 设置里把 `terminal.integrated.tabs.title` 改成 `${sequence}` 才能看到标题。
|
|
164
|
-
-
|
|
164
|
+
- **用户的提问**:带一条深灰色背景(横跨整个窗口宽度),和回复一眼就能分开。背景条是用当前背景色清除行尾(`\x1b[K`)画出来的,不靠补空格,窗口变宽变窄都正好铺满。
|
|
165
|
+
- **页脚**:输入框下方右侧常驻当前模型和 effort(如 `sonnet-5-5 · high`);没发送 effort(auto)或该模型不支持时只显示模型;窄窗口放不下时先让位给"上下文快满"等提示,再不行就不显示。模型前面是**当前对话累计花费**(如 `$1.23 · opus-5-5 · effort high`):按官方价(和网关扣费同一张价目表,含 5 分钟/1 小时缓存写入、长上下文价)把每个请求算进去,子 agent、压缩、话题标题、WebFetch 都算,按 Esc 中断的请求按已读入的输入和估算的已输出部分算;记在会话文件里,重启、`/resume` 后接着累计,`/clear` 从零开始。`/cost` 第一行也显示它,`-p --output-format json` 的结果带本次运行的 `total_cost_usd`。不认识价格的模型(第三方端点的自定义模型)不计;网关上游分组倍率不是 1 的账户以网关流水为准。花费前面是**提示词缓存时钟**(和官方 VS Code 扩展的 prompt cache clock 一致):`◷ 12m` 表示对话的缓存估计还能保留 12 分钟(按 5 分钟或 1 小时 TTL 倒数,每次用到缓存的回复重新开始计时);倒数结束后分钟数消失、图标变红,直到下一次回复——这时发下一条消息会把整个对话重新写入缓存,更慢也更贵;压缩之后到下一次回复之前也是红色,因为压缩后的对话还没进缓存。时钟记在会话文件里,重启、`/resume` 后接着显示(压缩后关掉的会话恢复时是冷的);还没有回复报告过缓存用量时不显示。窄窗口先省掉花费,再省掉时钟。
|
|
165
166
|
- **缩放窗口**:拖动期间不重绘,停下约 80ms 后从左上角整屏绝对重绘一次,输入框不会再留下残影。注意:很短的会话里这次重绘会覆盖终端上方 shell 的内容。
|
|
166
167
|
|
|
167
168
|
## 自动重启:改了自己的代码马上生效
|
|
@@ -304,6 +305,7 @@ claude-pro -p --plan "给出重构方案" # 只调研、只
|
|
|
304
305
|
| `ctrl+l` | 清屏重绘 |
|
|
305
306
|
| `alt+v` | 粘贴剪贴板图片 |
|
|
306
307
|
| `PgUp` / `PgDn`、滚轮、`Ctrl+Home` / `Ctrl+End` | 全屏模式下滚动对话 / 到开头 / 回到最新 |
|
|
308
|
+
| `ctrl+f` | 全屏模式下在对话里查找(像 VS Code):输入框的位置换成查找框,边输入边高亮所有匹配(不分大小写,当前这个橙色、其余黄色),右上角显示"第几个/共几个"(`No results` 表示没有)。先跳到视图底部往上最近的一个;`Enter` 或 `↑` 跳到更早的一个,`Shift+Enter` 或 `↓` 跳到更新的一个,到头后绕回;`PgUp`/`PgDn`、滚轮照常滚动;`Esc` 关闭,画面停在当前位置(`Ctrl+End` 回到底部)。再按 `ctrl+f` 会带上上次的关键字(已选中,直接输入就替换);先用鼠标选中文字再按,会搜选中的文字。查找时有新输出,匹配会跟着更新。经典模式(`/tui default`)的对话在终端自己的滚动缓冲里,请用终端自带的查找 |
|
|
307
309
|
| `↑` / `↓` | 在多行输入(包括自动折行的长文字)里上下移动光标;光标在第一行时 `↑` 先移到开头、再按才翻到上一条历史消息,`↓` 在最后一行同理 |
|
|
308
310
|
| `Tab` | 补全命令 / 文件 |
|
|
309
311
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanzc/claude-pro",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "claude-pro: a self-implemented coding agent for Anthropic-compatible APIs or local Claude Code subscription login. Full access with no permission prompts; file edits are checkpointed and can be rewound.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/agent.js
CHANGED
|
@@ -780,6 +780,7 @@ export class Agent {
|
|
|
780
780
|
}
|
|
781
781
|
const note = { role: 'user', content: [{ type: 'text', text: partialSummaryMessage(part, summary) }] };
|
|
782
782
|
this.replaceMessages(part === 'before' ? [note, ...after] : [...before, note]);
|
|
783
|
+
if (part === 'before') this.cache.noteCompacted(); // from its first message on: nothing of it is cached (the start of "after" still is)
|
|
783
784
|
if (part === 'after') this.todos = []; // what they were about may be summarized away; the model keeps them in the summary
|
|
784
785
|
this.lastContextTokens = estimateTokens(this.messages) + estimateTokens(this.toolSchemas()) + 2000;
|
|
785
786
|
this.tokensSinceUsage = 0;
|
|
@@ -809,6 +810,7 @@ export class Agent {
|
|
|
809
810
|
}
|
|
810
811
|
const todoNote = this.todos.length ? `\n\n<system-reminder>Current todo list:\n${this.todos.map((t) => `- [${t.status}] ${t.content}`).join('\n')}</system-reminder>` : '';
|
|
811
812
|
this.replaceMessages([{ role: 'user', content: [{ type: 'text', text: continuationMessage(summary, { auto }) + todoNote }] }]);
|
|
813
|
+
this.cache.noteCompacted();
|
|
812
814
|
this.refreshSystemPrompt?.(); // the conversation starts over: it may start from a freshly built system prompt
|
|
813
815
|
this.fileState.clear();
|
|
814
816
|
if (!this.isSubagent) this.checkpoints?.markConversationStale(); // the checkpoints are the main conversation's: a sub-agent's own compaction does not end them
|
package/src/api.js
CHANGED
|
@@ -10,6 +10,7 @@ import { readGatewayLogin, fetchGatewayModels, cachedGatewayModel, gatewayEffort
|
|
|
10
10
|
import { createCodexFetch, codexModels, codexEffort, nearestEffort } from './codex/client.js';
|
|
11
11
|
import { SIGNATURE_PREFIX } from './codex/translate-request.js';
|
|
12
12
|
import { fitImagesIn } from './utils/images.js';
|
|
13
|
+
import { costOf } from './pricing.js';
|
|
13
14
|
|
|
14
15
|
let cached = null;
|
|
15
16
|
|
|
@@ -342,6 +343,14 @@ export function isAbort(err) {
|
|
|
342
343
|
return err?.name === 'AbortError' || err instanceof Anthropic.APIUserAbortError || /^(?:request was )?aborted\.?$/i.test(err?.message || '');
|
|
343
344
|
}
|
|
344
345
|
|
|
346
|
+
// USD for a request at the list prices (see pricing.js), by the model that answered, else the one asked for;
|
|
347
|
+
// null when neither has a price. A cost goes to config.onCost.
|
|
348
|
+
function charge(config, requested, served, usage) {
|
|
349
|
+
const cost = costOf(served, usage) ?? costOf(requested, usage);
|
|
350
|
+
if (cost > 0) config.onCost?.(cost);
|
|
351
|
+
return cost;
|
|
352
|
+
}
|
|
353
|
+
|
|
345
354
|
// Stream a message. `onEvent` receives:
|
|
346
355
|
// {type:'block_start', index, block}
|
|
347
356
|
// {type:'text', index, text} text delta
|
|
@@ -354,7 +363,8 @@ export function isAbort(err) {
|
|
|
354
363
|
// {type:'rate_limit_resume', waitedMs} the repeated request was accepted: the pause is over
|
|
355
364
|
// {type:'reset'} partial output from a failed attempt must be discarded
|
|
356
365
|
// `waitForRateLimit: false` reports a rate limit as an error instead of waiting for it (side queries
|
|
357
|
-
// where nobody wants to sit through a pause). Returns {message, usage, stopReason}.
|
|
366
|
+
// where nobody wants to sit through a pause). Returns {message, usage, stopReason, model, cost, cache}.
|
|
367
|
+
// What each request costs (an interrupted one too) goes to config.onCost, the conversation's running total.
|
|
358
368
|
export async function streamMessage(config, request, { signal, onEvent = () => {}, waitForRateLimit = true } = {}) {
|
|
359
369
|
if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] });
|
|
360
370
|
// A token that has expired (or is about to) is renewed first, as the official client does.
|
|
@@ -382,6 +392,10 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
|
|
|
382
392
|
for (;;) {
|
|
383
393
|
let emitted = false;
|
|
384
394
|
const startedAt = Date.now(); // the cache TTL counts from the start of the request, not from the end of the reply
|
|
395
|
+
const blocks = [];
|
|
396
|
+
const jsonBufs = [];
|
|
397
|
+
let usage = {};
|
|
398
|
+
let model = params.model;
|
|
385
399
|
try {
|
|
386
400
|
const stream = await client.messages.create(params, options);
|
|
387
401
|
if (paused) {
|
|
@@ -389,11 +403,7 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
|
|
|
389
403
|
paused = null;
|
|
390
404
|
onEvent({ type: 'rate_limit_resume', waitedMs: overloaded ? retryState.overloadedWaitMs : retryState.waitedMs, ...(overloaded ? { overloaded } : {}) });
|
|
391
405
|
}
|
|
392
|
-
const blocks = [];
|
|
393
|
-
const jsonBufs = [];
|
|
394
|
-
let usage = {};
|
|
395
406
|
let stopReason = null;
|
|
396
|
-
let model = params.model;
|
|
397
407
|
let stopped = false;
|
|
398
408
|
for await (const ev of stream) {
|
|
399
409
|
switch (ev.type) {
|
|
@@ -457,9 +467,15 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
|
|
|
457
467
|
const key = cacheKey(config, params.model, login);
|
|
458
468
|
cacheRefused.set(key, new Set([...(cacheRefused.get(key) || []), ...refused]));
|
|
459
469
|
}
|
|
460
|
-
|
|
470
|
+
const cost = charge(config, params.model, model, usage);
|
|
471
|
+
return { message: { role: 'assistant', content }, usage, stopReason, model, cost, cache: { ...cachePlanOf(params), fingerprint: fingerprint(params), at: startedAt } };
|
|
461
472
|
} catch (raw) {
|
|
462
|
-
if (signal?.aborted || isAbort(raw))
|
|
473
|
+
if (signal?.aborted || isAbort(raw)) {
|
|
474
|
+
// An interrupted reply is billed for what it had read and written: the output so far is estimated.
|
|
475
|
+
const written = blocks.reduce((n, b) => n + (b?.text?.length || 0) + (b?.thinking?.length || 0), 0) + jsonBufs.join('').length;
|
|
476
|
+
charge(config, params.model, model, { ...usage, output_tokens: Math.max(usage.output_tokens || 0, Math.ceil(written / 4)) });
|
|
477
|
+
throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: raw?.partial || [] });
|
|
478
|
+
}
|
|
463
479
|
const err = describeError(raw, config);
|
|
464
480
|
// The token was refused. A running native Claude Code session may have renewed the shared token
|
|
465
481
|
// while this request was in flight: read it again. If it is the same one, it may have been revoked
|
package/src/cache.js
CHANGED
|
@@ -80,6 +80,7 @@ export class CacheStats {
|
|
|
80
80
|
this.ttl = '5m';
|
|
81
81
|
this.model = null; // the model that answered the last request
|
|
82
82
|
this.effort = null;
|
|
83
|
+
this.clockUntil = null; // the footer's prompt cache clock (see clock): null shows none
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// The history was cut or replaced (compaction, /rewind, /resume): the next request will not find the old
|
|
@@ -88,6 +89,21 @@ export class CacheStats {
|
|
|
88
89
|
this.expected = null;
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
// A compaction replaced the conversation: none of it is in the cache until the next response.
|
|
93
|
+
noteCompacted() {
|
|
94
|
+
this.noteRewrite();
|
|
95
|
+
if (this.clockUntil != null) this.clockUntil = 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The prompt cache clock of the footer, as in Claude Code's VS Code extension: the minutes left before what
|
|
99
|
+
// the last request left in the cache runs out (each response that used the cache starts it again), or cold
|
|
100
|
+
// once it has run out and right after a compaction, until the next response. null before any response
|
|
101
|
+
// reported cache use. A resumed conversation starts from the clockUntil its session file kept.
|
|
102
|
+
clock(now = Date.now()) {
|
|
103
|
+
if (this.clockUntil == null) return null;
|
|
104
|
+
return now < this.clockUntil ? { minutes: Math.ceil((this.clockUntil - now) / 60_000) } : { cold: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
// One finished request. `at` is when it started: the TTL counts from there, not from the end of the reply.
|
|
92
108
|
record({ usage, fingerprint: fp = null, ttl = '5m', caching = true, at = Date.now() }) {
|
|
93
109
|
const u = usage || {};
|
|
@@ -107,6 +123,7 @@ export class CacheStats {
|
|
|
107
123
|
this.expected = caching ? read + write : null;
|
|
108
124
|
this.expiresAt = at + TTL_MS[ttl];
|
|
109
125
|
this.ttl = ttl;
|
|
126
|
+
if (caching && (read || write)) this.clockUntil = this.expiresAt;
|
|
110
127
|
if (fp) {
|
|
111
128
|
this.prev = fp;
|
|
112
129
|
this.model = fp.model;
|
package/src/commands.js
CHANGED
|
@@ -463,6 +463,7 @@ export function buildCommands(app) {
|
|
|
463
463
|
const b = a.subUsage;
|
|
464
464
|
const cacheLines = a.cache.describe();
|
|
465
465
|
out([
|
|
466
|
+
`Total cost: ${formatUsd(app.cost)} ${c.dim('for this conversation, at list prices (sub-agents, compaction and side requests included)')}`,
|
|
466
467
|
`Total input: ${formatTokens(u.input + u.cacheRead + u.cacheWrite)} tokens ${c.dim(`(${formatTokens(u.cacheRead)} cache read, ${formatTokens(u.cacheWrite)} cache write${u.cacheWrite1h ? `, ${formatTokens(u.cacheWrite1h)} of it for 1 hour` : ''})`)}`,
|
|
467
468
|
`Total output: ${formatTokens(u.output)} tokens`,
|
|
468
469
|
`API requests: ${u.requests}${u.compactions ? c.dim(` + ${u.compactions} to compact the conversation`) : ''}`,
|
|
@@ -623,7 +624,7 @@ export function buildCommands(app) {
|
|
|
623
624
|
reloadConfig();
|
|
624
625
|
app.screen.setFull(want === 'fullscreen');
|
|
625
626
|
out(want === 'fullscreen'
|
|
626
|
-
? `Fullscreen: the input box stays at the bottom; scroll with the wheel, PgUp/PgDn, ctrl+Home/End
|
|
627
|
+
? `Fullscreen: the input box stays at the bottom; scroll with the wheel, PgUp/PgDn, ctrl+Home/End; drag to select and copy; ctrl+f to find in the conversation. (Saved in ${file})`
|
|
627
628
|
: `Default renderer: the conversation scrolls in the terminal. (Saved in ${file})`);
|
|
628
629
|
},
|
|
629
630
|
});
|
package/src/pricing.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What a request costs, in USD, for the running total of a conversation (the footer, /cost). The prices are the
|
|
2
|
+
// official per-million-token ones the gateway bills at (its pricing.mjs); a gateway upstream group's multiplier
|
|
3
|
+
// (1 unless the account is set otherwise) is not known here. A Claude model that is not in the table is priced
|
|
4
|
+
// as the current model of its family, as the gateway serves it. longContext: a prompt (input + cache reads +
|
|
5
|
+
// cache writes) above `threshold` tokens is billed at those prices.
|
|
6
|
+
|
|
7
|
+
const PRICES = [
|
|
8
|
+
{ id: 'claude-fable-5-1', family: 'fable', input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 0.25 },
|
|
9
|
+
{ id: 'claude-opus-5-5', family: 'opus', input: 4, output: 20, cacheWrite5m: 5, cacheWrite1h: 8, cacheRead: 0.2 },
|
|
10
|
+
{ id: 'claude-sonnet-5-5', family: 'sonnet', input: 2, output: 10, cacheWrite5m: 2.5, cacheWrite1h: 4, cacheRead: 0.1 },
|
|
11
|
+
{
|
|
12
|
+
id: 'claude-haiku-5-5', family: 'haiku', input: 0.1, output: 0.5, cacheWrite5m: 0.125, cacheWrite1h: 0.2, cacheRead: 0.01,
|
|
13
|
+
longContext: { threshold: 100_000, input: 0.5, output: 2.5, cacheWrite5m: 0.625, cacheWrite1h: 1, cacheRead: 0.05 },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: 'gpt-6-astra', input: 10, output: 50, cacheRead: 1, cacheWrite5m: 12.5, cacheWrite1h: 12.5,
|
|
17
|
+
longContext: { threshold: 272_000, input: 20, output: 75, cacheWrite5m: 25, cacheWrite1h: 25, cacheRead: 2 },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'gpt-6-1-sol', input: 2, output: 10, cacheRead: 0.1, cacheWrite5m: 2.5, cacheWrite1h: 2.5,
|
|
21
|
+
longContext: { threshold: 272_000, input: 4, output: 15, cacheWrite5m: 5, cacheWrite1h: 5, cacheRead: 0.2 },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'gpt-6-luna', input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite5m: 0.125, cacheWrite1h: 0.125,
|
|
25
|
+
longContext: { threshold: 272_000, input: 0.2, output: 0.75, cacheWrite5m: 0.25, cacheWrite1h: 0.25, cacheRead: 0.02 },
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// claude-opus-5-5, anthropic/claude-opus-5-5[1m], claude-haiku-5-5-20261001, gpt-6.1-sol:high -> the table's id form
|
|
30
|
+
function cleanModel(model) {
|
|
31
|
+
const value = String(model || '').trim().toLowerCase();
|
|
32
|
+
return (value.includes('/') ? value.slice(value.lastIndexOf('/') + 1) : value)
|
|
33
|
+
.replace(/\[1m\]$/, '')
|
|
34
|
+
.replace(/[:@](none|minimal|low|medium|high|xhigh|max|ultra)$/, '')
|
|
35
|
+
.replace(/(\d)\.(\d)/g, '$1-$2')
|
|
36
|
+
.replace(/-\d{8}$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The prices of a model, or null when it has none here (a model of another endpoint).
|
|
40
|
+
export function priceOf(model) {
|
|
41
|
+
const id = cleanModel(model);
|
|
42
|
+
const exact = PRICES.find((p) => p.id === id);
|
|
43
|
+
if (exact) return exact;
|
|
44
|
+
const family = id.startsWith('claude-') && id.match(/\b(fable|opus|sonnet|haiku)\b/)?.[1];
|
|
45
|
+
return (family && PRICES.find((p) => p.family === family)) || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// USD for one request's usage as the API reports it; null for a model with no price.
|
|
49
|
+
export function costOf(model, usage = {}) {
|
|
50
|
+
const price = priceOf(model);
|
|
51
|
+
if (!price) return null;
|
|
52
|
+
const n = (v) => Math.max(0, Number(v) || 0);
|
|
53
|
+
const input = n(usage.input_tokens);
|
|
54
|
+
const read = n(usage.cache_read_input_tokens);
|
|
55
|
+
const oneHour = n(usage.cache_creation?.ephemeral_1h_input_tokens);
|
|
56
|
+
const writes = Math.max(n(usage.cache_creation_input_tokens), n(usage.cache_creation?.ephemeral_5m_input_tokens) + oneHour);
|
|
57
|
+
const p = price.longContext && input + read + writes > price.longContext.threshold ? price.longContext : price;
|
|
58
|
+
return (input * p.input + n(usage.output_tokens) * p.output + read * p.cacheRead + (writes - oneHour) * p.cacheWrite5m + oneHour * p.cacheWrite1h) / 1e6;
|
|
59
|
+
}
|
package/src/print.js
CHANGED
|
@@ -53,8 +53,16 @@ export async function runPrint({ prompt, flags, cwd, stdout = process.stdout, st
|
|
|
53
53
|
};
|
|
54
54
|
const runtime = await createRuntime({ cwd, flags, io });
|
|
55
55
|
let store = new SessionStore(cwd);
|
|
56
|
+
let cost = 0; // what this run's requests cost (total_cost_usd)
|
|
57
|
+
let conversationCost = 0; // and the conversation's, kept with the session
|
|
58
|
+
runtime.config.onCost = (usd) => {
|
|
59
|
+
cost += usd;
|
|
60
|
+
conversationCost += usd;
|
|
61
|
+
store.setCost(conversationCost);
|
|
62
|
+
};
|
|
56
63
|
if (flags.resumeFile) {
|
|
57
64
|
const loaded = SessionStore.load(flags.resumeFile);
|
|
65
|
+
conversationCost = loaded.cost;
|
|
58
66
|
runtime.agent.messages = loaded.messages;
|
|
59
67
|
runtime.agent.restoreFileState(loaded.messages, loaded.times);
|
|
60
68
|
runtime.agent.lastActivityAt = loaded.lastAt;
|
|
@@ -97,6 +105,7 @@ export async function runPrint({ prompt, flags, cwd, stdout = process.stdout, st
|
|
|
97
105
|
session_id: store.id,
|
|
98
106
|
duration_ms: Date.now() - started,
|
|
99
107
|
num_turns: a.usage.requests,
|
|
108
|
+
total_cost_usd: Number(cost.toFixed(6)),
|
|
100
109
|
usage: { input_tokens: used.input, output_tokens: used.output, cache_read_input_tokens: used.cacheRead, cache_creation_input_tokens: used.cacheWrite },
|
|
101
110
|
});
|
|
102
111
|
} else {
|
package/src/restart.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// process to the next, the check that the new code starts before it is trusted, and the watch on the
|
|
3
3
|
// source files.
|
|
4
4
|
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { spawn } from 'node:child_process';
|
|
@@ -83,18 +84,34 @@ export function runSelfCheck({ root = INSTALL_DIR, signal, timeoutMs = 60_000 }
|
|
|
83
84
|
|
|
84
85
|
// Watch the program's own source (src/ and bin/) and call `onChange(file)` once the files have stopped
|
|
85
86
|
// changing for a moment. Returns {close()}. A platform that cannot watch just never calls back.
|
|
87
|
+
// Only a change of content counts: Windows also reports a file whose last access time was updated, which
|
|
88
|
+
// reading it does (at most once an hour: another window starting, npm packing it, a search).
|
|
86
89
|
export function watchSource(root, onChange) {
|
|
87
90
|
const watchers = [];
|
|
88
91
|
let timer = null;
|
|
89
|
-
const
|
|
90
|
-
|
|
92
|
+
const SOURCE = /\.(m?js|json)$/i;
|
|
93
|
+
const digest = (file) => { try { return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('base64'); } catch { return null; } };
|
|
94
|
+
const known = new Map(); // file -> digest of its content when last seen (null: not there)
|
|
95
|
+
for (const dir of ['src', 'bin']) {
|
|
96
|
+
let names = [];
|
|
97
|
+
try { names = fs.readdirSync(path.join(root, dir), { recursive: true }); } catch { /* not there */ }
|
|
98
|
+
for (const name of names) if (SOURCE.test(name)) known.set(path.join(root, dir, name), digest(path.join(root, dir, name)));
|
|
99
|
+
}
|
|
100
|
+
const hit = (dir) => (_event, name) => {
|
|
101
|
+
if (name && !SOURCE.test(String(name))) return;
|
|
102
|
+
if (name) {
|
|
103
|
+
const file = path.join(root, dir, String(name));
|
|
104
|
+
const now = digest(file);
|
|
105
|
+
if (now === (known.get(file) ?? null)) return;
|
|
106
|
+
known.set(file, now);
|
|
107
|
+
}
|
|
91
108
|
clearTimeout(timer);
|
|
92
109
|
timer = setTimeout(() => { try { onChange(String(name || '')); } catch { /* the callback's problem */ } }, 400);
|
|
93
110
|
timer.unref?.();
|
|
94
111
|
};
|
|
95
112
|
for (const dir of ['src', 'bin']) {
|
|
96
113
|
try {
|
|
97
|
-
const watcher = fs.watch(path.join(root, dir), { recursive: true, persistent: false }, hit);
|
|
114
|
+
const watcher = fs.watch(path.join(root, dir), { recursive: true, persistent: false }, hit(dir));
|
|
98
115
|
watcher.on('error', () => { /* a directory that vanished, a watch limit: not worth a crash */ });
|
|
99
116
|
watchers.push(watcher);
|
|
100
117
|
} catch { /* not supported here */ }
|
package/src/session.js
CHANGED
|
@@ -77,6 +77,15 @@ export class SessionStore {
|
|
|
77
77
|
// The name the user gave the conversation with /rename (null: none again); it wins over the topic.
|
|
78
78
|
setName(name) { this.append({ type: 'meta', name, at: Date.now() }); }
|
|
79
79
|
reset(messages) { this.append({ type: 'reset', messages, at: Date.now() }); }
|
|
80
|
+
// What the conversation's requests have cost so far (USD, see pricing.js), written after each one; the newest wins.
|
|
81
|
+
setCost(usd) { this.append({ type: 'meta', cost: usd, at: Date.now() }); }
|
|
82
|
+
// Until when the conversation's prompt cache lasts (ms since epoch; 0: cold), for the footer's clock of a
|
|
83
|
+
// resumed conversation (see CacheStats.clock). Written after each of its requests and compactions.
|
|
84
|
+
setCacheUntil(until) {
|
|
85
|
+
if (until == null || until === this.cacheUntil) return;
|
|
86
|
+
this.cacheUntil = until;
|
|
87
|
+
this.append({ type: 'meta', cacheUntil: until, at: Date.now() });
|
|
88
|
+
}
|
|
80
89
|
// The system prompt this conversation runs behind, kept so that a resumed one continues behind the same prompt
|
|
81
90
|
// and so with the prompt cache it already has (see runtime.resumeSystemPrompt). Written when it first appears
|
|
82
91
|
// and whenever it changes (after a compaction); the newest one wins when the session is loaded.
|
|
@@ -114,6 +123,8 @@ export class SessionStore {
|
|
|
114
123
|
topic: typeof meta.topic === 'string' && meta.topic.trim() ? meta.topic : null,
|
|
115
124
|
name: typeof meta.name === 'string' && meta.name.trim() ? meta.name : null,
|
|
116
125
|
systemPrompt: typeof meta.systemPrompt === 'string' && meta.systemPrompt.trim() ? meta.systemPrompt : null,
|
|
126
|
+
cost: Number(meta.cost) > 0 ? Number(meta.cost) : 0,
|
|
127
|
+
cacheUntil: Number.isFinite(meta.cacheUntil) && meta.cacheUntil >= 0 ? meta.cacheUntil : null,
|
|
117
128
|
};
|
|
118
129
|
}
|
|
119
130
|
|
package/src/ui/ansi.js
CHANGED
|
@@ -51,6 +51,8 @@ export const c = {
|
|
|
51
51
|
plan: hex('#48968c'),
|
|
52
52
|
edits: hex('#af87ff'),
|
|
53
53
|
code: hex('#b1b9f9'),
|
|
54
|
+
userBg: hex('#373737', true), // the band behind the user's messages (see renderUserMessage)
|
|
55
|
+
userText: hex('#e4e4e4'),
|
|
54
56
|
diffAdd: hex('#225c2b', true),
|
|
55
57
|
diffDel: hex('#7a2936', true),
|
|
56
58
|
diffAddWord: hex('#38a660', true),
|
package/src/ui/render.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Formatting of transcript items: user prompts, tool calls and results, diffs, todos.
|
|
2
2
|
|
|
3
|
-
import { c, stringWidth, truncate, padEnd, wordWrap, wrapLine } from './ansi.js';
|
|
3
|
+
import { c, colorEnabled, stringWidth, truncate, padEnd, wordWrap, wrapLine } from './ansi.js';
|
|
4
4
|
import { displayPath } from '../utils/paths.js';
|
|
5
5
|
import { renderMarkdown } from './markdown.js';
|
|
6
6
|
|
|
@@ -129,14 +129,19 @@ export function renderToolResult(entry, { verbose = false, width = 100, cwd }) {
|
|
|
129
129
|
return resultLines(shown);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// What the user wrote, on a band of background colour across the window, so that it stands apart from the replies
|
|
133
|
+
// (as in Claude Code). The band is the rest of each row erased in that colour (\x1b[K), which needs no padding and
|
|
134
|
+
// so fits any width the window is given later.
|
|
135
|
+
const userRow = (s) => (colorEnabled ? c.userBg(s + '\x1b[K') : s);
|
|
136
|
+
|
|
132
137
|
export function renderUserMessage(text, width) {
|
|
133
138
|
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
134
139
|
const out = [];
|
|
135
140
|
lines.forEach((l, i) => {
|
|
136
141
|
const wrapped = wordWrap(l, Math.max(10, width - 3));
|
|
137
|
-
wrapped.forEach((wl, j) => out.push((i === 0 && j === 0 ? c.gray('> ') : ' ') + c.
|
|
142
|
+
wrapped.forEach((wl, j) => out.push(userRow((i === 0 && j === 0 ? c.gray('> ') : ' ') + c.userText(wl))));
|
|
138
143
|
});
|
|
139
|
-
if (out.length > 30) return [...out.slice(0, 25), c.
|
|
144
|
+
if (out.length > 30) return [...out.slice(0, 25), userRow(c.gray(` … +${out.length - 25} lines`))];
|
|
140
145
|
return out;
|
|
141
146
|
}
|
|
142
147
|
|
package/src/ui/repl.js
CHANGED
|
@@ -8,7 +8,7 @@ import readline from 'node:readline';
|
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import { c, ESC, stringWidth, truncate, padEnd, wordWrap, colorEnabled } from './ansi.js';
|
|
10
10
|
import { Screen, queryCursorRow, mouseFilter } from './screen.js';
|
|
11
|
-
import { Editor } from './editor.js';
|
|
11
|
+
import { Editor, topBorder } from './editor.js';
|
|
12
12
|
import { SelectDialog } from './dialogs.js';
|
|
13
13
|
import { MarkdownStream, renderMarkdown } from './markdown.js';
|
|
14
14
|
import {
|
|
@@ -18,7 +18,7 @@ import { createRuntime } from '../runtime.js';
|
|
|
18
18
|
import { SessionStore, firstUserText } from '../session.js';
|
|
19
19
|
import { defaultEffort, effortSupported, effortToSend, quickQuery } from '../api.js';
|
|
20
20
|
import { CONFIG_DIR, VERSION, APP_NAME, INSTALL_DIR } from '../config.js';
|
|
21
|
-
import { readGatewayLogin } from '../gateway.js';
|
|
21
|
+
import { readGatewayLogin, formatUsd } from '../gateway.js';
|
|
22
22
|
import { TOPIC_TITLE_SYSTEM, topicTitlePrompt } from '../prompts.js';
|
|
23
23
|
import { buildCommands } from '../commands.js';
|
|
24
24
|
import { canRestart, writeRestartState, runSelfCheck, watchSource, restartNote, RESTART_EXIT_CODE, MAX_RESTARTS, RESTART_WINDOW_MS } from '../restart.js';
|
|
@@ -31,7 +31,7 @@ import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard, read
|
|
|
31
31
|
import { fitImagesIn } from '../utils/images.js';
|
|
32
32
|
import { downloadsDir, filesToSend, formatBytes } from '../utils/downloads.js';
|
|
33
33
|
import { copyUnique } from '../transfer.js';
|
|
34
|
-
import { checkForUpdate, installUpdate, installedVersion, isNewer, CHECK_EVERY_MS } from '../updater.js';
|
|
34
|
+
import { checkForUpdate, installUpdate, installedVersion, installedByNpm, isNewer, CHECK_EVERY_MS } from '../updater.js';
|
|
35
35
|
import { PeerNode, peersEnabled, findPeer, resolvePeer, machineName, cleanName, peerMessagePrompt } from '../peers.js';
|
|
36
36
|
import { RemoteControl, transcriptItems, onlineEnabled, REMOTE_WIDTH } from '../remote.js';
|
|
37
37
|
import { qrLines } from '../qr.js';
|
|
@@ -196,6 +196,7 @@ export class App {
|
|
|
196
196
|
this.remotePeersTimer = null;
|
|
197
197
|
this.transfers = new Map(); // files on their way to or from other sessions, for the footer
|
|
198
198
|
this.transferSeq = 0;
|
|
199
|
+
this.cost = 0; // what this conversation's requests have cost so far (USD at list prices): the footer and /cost
|
|
199
200
|
this.fileIndex = new FileIndex(cwd, () => this.runtime?.config);
|
|
200
201
|
this.store = new SessionStore(cwd);
|
|
201
202
|
// What the terminal prints goes to the remote page too, except what it is told about as events.
|
|
@@ -245,6 +246,12 @@ export class App {
|
|
|
245
246
|
this.runtime = await createRuntime({ cwd: this.cwd, flags: this.flags, io: this });
|
|
246
247
|
// The conversation's id, sent to the gateway with each request (see streamMessage); it follows /clear and /resume.
|
|
247
248
|
Object.defineProperty(this.runtime.config, 'sessionId', { get: () => this.store.id, configurable: true });
|
|
249
|
+
// Every request's cost (the conversation's, its sub-agents', side queries'), kept with the session.
|
|
250
|
+
this.runtime.config.onCost = (usd) => {
|
|
251
|
+
this.cost += usd;
|
|
252
|
+
this.store.setCost(this.cost);
|
|
253
|
+
this.screen.schedule();
|
|
254
|
+
};
|
|
248
255
|
this.commands = buildCommands(this);
|
|
249
256
|
if (pinOff(this.config.env.CLAUDE_PRO_PIN_INPUT)) { this.screen.pin = false; this.screen.pinned = null; }
|
|
250
257
|
// What the session showed before a restart: scrolled back to in fullscreen, repainted after a resize otherwise.
|
|
@@ -275,7 +282,11 @@ export class App {
|
|
|
275
282
|
this.autoStartRemote(restart);
|
|
276
283
|
|
|
277
284
|
this.screen.setLive((w) => this.renderLive(w));
|
|
278
|
-
this.spinnerTimer = setInterval(() => {
|
|
285
|
+
this.spinnerTimer = setInterval(() => {
|
|
286
|
+
const clock = this.cacheClock(); // the footer's clock counts down while nothing else is drawn
|
|
287
|
+
if (this.busy || this.active.length || clock !== this.shownClock) this.screen.schedule();
|
|
288
|
+
this.shownClock = clock;
|
|
289
|
+
}, 120);
|
|
279
290
|
if (restart) this.continueAfterRestart(restart);
|
|
280
291
|
else if (initialPrompt?.trim()) this.onSubmit(initialPrompt, initialPrompt);
|
|
281
292
|
// Nothing configured yet: the gateway is the default, and its login comes first, as in Claude Code.
|
|
@@ -992,9 +1003,10 @@ export class App {
|
|
|
992
1003
|
if (!canRestart() || /^(0|false|no|off)$/i.test(this.config.env.CLAUDE_PRO_AUTO_RESTART || '')) return;
|
|
993
1004
|
this.sourceWatch = watchSource(INSTALL_DIR, (file) => {
|
|
994
1005
|
this.sourceChanged = file || true;
|
|
995
|
-
// another window (or npm) installed a new version: say so, and load it once this session is idle
|
|
1006
|
+
// another window (or npm) installed a new version: say so, and load it once this session is idle. In a
|
|
1007
|
+
// source checkout a newer version in package.json is a release being made, not an install.
|
|
996
1008
|
const disk = installedVersion();
|
|
997
|
-
if (disk !== VERSION && isNewer(disk, VERSION) && this.updateRestart !== disk) {
|
|
1009
|
+
if (installedByNpm() && disk !== VERSION && isNewer(disk, VERSION) && this.updateRestart !== disk) {
|
|
998
1010
|
this.updateRestart = disk;
|
|
999
1011
|
this.pendingUpdate = null;
|
|
1000
1012
|
this.notice(`${APP_NAME} ${disk} was installed (by another window or npm); this one restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
|
|
@@ -1208,6 +1220,13 @@ export class App {
|
|
|
1208
1220
|
if (key.name === 'pagedown') { this.screen.scrollBy(-half); return; }
|
|
1209
1221
|
if (key.ctrl && key.name === 'home') { this.screen.scrollBy('top'); return; }
|
|
1210
1222
|
if (key.ctrl && key.name === 'end') { this.screen.scrollBy('bottom'); return; }
|
|
1223
|
+
// ctrl+f looks for what is selected, if anything
|
|
1224
|
+
if (key.ctrl && key.name === 'f' && !this.dialogs.length) {
|
|
1225
|
+
const seed = this.screen.selection ? this.screen.selectedText() : '';
|
|
1226
|
+
this.screen.clearSelection();
|
|
1227
|
+
this.openFind(seed);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1211
1230
|
// A selection: ctrl+c copies it (and does not interrupt), esc only clears it, any other key clears it and
|
|
1212
1231
|
// then does what it does.
|
|
1213
1232
|
if (this.screen.selection) {
|
|
@@ -1216,6 +1235,7 @@ export class App {
|
|
|
1216
1235
|
if (key.name === 'escape') return;
|
|
1217
1236
|
}
|
|
1218
1237
|
}
|
|
1238
|
+
if (key.ctrl && key.name === 'c' && this.finder && !this.dialogs.length) { this.closeFind(); return; } // not the hidden input's text
|
|
1219
1239
|
if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
|
|
1220
1240
|
const dialog = this.dialogs[0];
|
|
1221
1241
|
if (dialog) {
|
|
@@ -1223,6 +1243,8 @@ export class App {
|
|
|
1223
1243
|
this.screen.schedule();
|
|
1224
1244
|
return;
|
|
1225
1245
|
}
|
|
1246
|
+
if (key.ctrl && key.name === 'f') { this.openFind(); return; }
|
|
1247
|
+
if (this.finder) { this.findKey(str, key); this.screen.schedule(); return; }
|
|
1226
1248
|
if (key.name === 'tab' && key.shift) { this.runtime.modes.toggle(); this.screen.schedule(); return; }
|
|
1227
1249
|
// alt+v like Claude Code on Windows (ctrl+v is the terminal's own text paste there).
|
|
1228
1250
|
if ((key.meta || key.ctrl) && key.name === 'v') { this.pasteImage(); return; }
|
|
@@ -1378,11 +1400,102 @@ export class App {
|
|
|
1378
1400
|
['PgUp/PgDn, wheel to scroll', 'ctrl + home/end to the top/bottom'],
|
|
1379
1401
|
['# to memorize', 'ctrl + t to toggle todos'],
|
|
1380
1402
|
['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
|
|
1381
|
-
['alt + v to paste an image', ''],
|
|
1403
|
+
['alt + v to paste an image', 'ctrl + f to find in the conversation'],
|
|
1382
1404
|
];
|
|
1383
1405
|
return rows.map(([a, b]) => c.dim(' ' + padEnd(a, 30) + b));
|
|
1384
1406
|
}
|
|
1385
1407
|
|
|
1408
|
+
// ---------------------------------------------------------------- finding in the conversation (ctrl+f)
|
|
1409
|
+
//
|
|
1410
|
+
// As in VS Code: a box takes the input box's place, the conversation shows the matches as they are typed, the
|
|
1411
|
+
// current one apart, and goes to it. In a conversation the newest matter most: the first one shown is the
|
|
1412
|
+
// nearest above the bottom of the view, enter (or ↑) goes on to older ones, shift+enter (or ↓) back to newer
|
|
1413
|
+
// ones, round from either end. Esc closes the box and leaves the view where it is. The text typed last comes
|
|
1414
|
+
// back selected (typing replaces it), or what is selected with the mouse. Fullscreen only: in the classic
|
|
1415
|
+
// renderer the conversation is in the terminal's own scrollback.
|
|
1416
|
+
|
|
1417
|
+
openFind(seed = '') {
|
|
1418
|
+
if (!this.screen.full) { this.flash('ctrl+f finds in the conversation in fullscreen (/tui); here use the terminal\'s own find', 'warn'); return; }
|
|
1419
|
+
if (this.finder && !seed.trim()) { this.finder.selected = Boolean(this.finder.text); this.screen.schedule(); return; } // open: select what is typed
|
|
1420
|
+
const text = (seed.split('\n').find((l) => l.trim()) || '').trim() || this.lastFind || '';
|
|
1421
|
+
if (!this.finder) this.screen.openFind();
|
|
1422
|
+
this.finder = { text, cursor: text.length, selected: Boolean(text), pasting: null };
|
|
1423
|
+
this.screen.setFind(text);
|
|
1424
|
+
this.screen.schedule();
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
closeFind() {
|
|
1428
|
+
if (!this.finder) return;
|
|
1429
|
+
this.lastFind = this.finder.text;
|
|
1430
|
+
this.finder = null;
|
|
1431
|
+
this.screen.closeFind();
|
|
1432
|
+
this.screen.schedule();
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
findKey(str, key) {
|
|
1436
|
+
const f = this.finder;
|
|
1437
|
+
const name = key.name;
|
|
1438
|
+
const edit = (text, cursor) => {
|
|
1439
|
+
Object.assign(f, { text, cursor, selected: false });
|
|
1440
|
+
this.screen.setFind(text);
|
|
1441
|
+
};
|
|
1442
|
+
const insert = (s) => {
|
|
1443
|
+
const t = s.replace(/[\r\n\t]+/g, ' ');
|
|
1444
|
+
if (!t) return;
|
|
1445
|
+
if (f.selected) edit(t, t.length);
|
|
1446
|
+
else edit(f.text.slice(0, f.cursor) + t + f.text.slice(f.cursor), f.cursor + t.length);
|
|
1447
|
+
};
|
|
1448
|
+
if (name === 'paste-start') { f.pasting = ''; return; }
|
|
1449
|
+
if (f.pasting !== null) {
|
|
1450
|
+
if (name === 'paste-end') { const t = f.pasting.split(/\r?\n/).find((l) => l.trim()) || ''; f.pasting = null; insert(t.trim()); }
|
|
1451
|
+
else if (name === 'return' || name === 'enter') f.pasting += '\n';
|
|
1452
|
+
else if (str) f.pasting += str;
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
if (name === 'escape') { this.closeFind(); return; }
|
|
1456
|
+
const back = (name === 'return' && (key.meta || key.shift)) || name === 'enter' || key.sequence === '\x1b[13;2u' || (name === 'f3' && key.shift);
|
|
1457
|
+
if (back || name === 'down' || (key.ctrl && name === 'n')) { f.selected = false; this.screen.findStep(-1); return; }
|
|
1458
|
+
if (name === 'return' || name === 'f3' || name === 'up' || (key.ctrl && name === 'p')) { f.selected = false; this.screen.findStep(1); return; }
|
|
1459
|
+
const chars = [...f.text.slice(0, f.cursor)];
|
|
1460
|
+
if (name === 'backspace') {
|
|
1461
|
+
if (f.selected) edit('', 0);
|
|
1462
|
+
else if (chars.length) { const keep = chars.slice(0, -1).join(''); edit(keep + f.text.slice(f.cursor), keep.length); }
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (name === 'delete') {
|
|
1466
|
+
if (f.selected) edit('', 0);
|
|
1467
|
+
else if (f.cursor < f.text.length) { const next = [...f.text.slice(f.cursor)].slice(1).join(''); edit(f.text.slice(0, f.cursor) + next, f.cursor); }
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (key.ctrl && name === 'u') { edit('', 0); return; }
|
|
1471
|
+
if (name === 'left') { f.selected = false; f.cursor = chars.slice(0, -1).join('').length; return; }
|
|
1472
|
+
if (name === 'right') { f.selected = false; f.cursor = Math.min(f.text.length, f.cursor + ([...f.text.slice(f.cursor)][0]?.length || 0)); return; }
|
|
1473
|
+
if (name === 'home' || (key.ctrl && name === 'a')) { f.selected = false; f.cursor = 0; return; }
|
|
1474
|
+
if (name === 'end' || (key.ctrl && name === 'e')) { f.selected = false; f.cursor = f.text.length; return; }
|
|
1475
|
+
if (str && !key.ctrl && !key.meta && !/[\x00-\x1f\x7f]/.test(str)) insert(str);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// The find box, in the input box's place: what is typed (selected: in reverse video), and how many matches.
|
|
1479
|
+
renderFind(width) {
|
|
1480
|
+
const f = this.finder;
|
|
1481
|
+
const boxW = Math.max(20, width - 1);
|
|
1482
|
+
const textW = boxW - 6;
|
|
1483
|
+
const s = this.screen.findStatus();
|
|
1484
|
+
const count = !f.text ? '' : s?.total ? `${s.index + 1}/${s.total}` : c.red('No results');
|
|
1485
|
+
const title = { left: 'Find in the conversation', right: count };
|
|
1486
|
+
// the part of the text that fits, the cursor in it
|
|
1487
|
+
let before = f.text.slice(0, f.cursor);
|
|
1488
|
+
while (before && stringWidth(before) > textW - 1) before = [...before].slice(1).join('');
|
|
1489
|
+
const shown = truncate(before + f.text.slice(f.cursor), textW, '');
|
|
1490
|
+
const content = f.selected && shown ? c.inverse(shown) : shown;
|
|
1491
|
+
const lines = [
|
|
1492
|
+
topBorder(boxW, c.yellow, title),
|
|
1493
|
+
c.yellow('│') + ' ' + c.yellow('⌕') + ' ' + padEnd(content, textW) + ' ' + c.yellow('│'),
|
|
1494
|
+
c.yellow('╰' + '─'.repeat(boxW - 2) + '╯'),
|
|
1495
|
+
];
|
|
1496
|
+
return { lines, cursor: { row: 1, col: 4 + stringWidth(before) } };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1386
1499
|
// ---------------------------------------------------------------- turns
|
|
1387
1500
|
|
|
1388
1501
|
// Where a mentioned path is (null when there is no such file): relative to the project root, which is
|
|
@@ -1674,6 +1787,8 @@ export class App {
|
|
|
1674
1787
|
this.remote?.register().catch(() => {});
|
|
1675
1788
|
}
|
|
1676
1789
|
this.firstPrompt = loaded.title;
|
|
1790
|
+
this.cost = loaded.cost;
|
|
1791
|
+
this.agent.cache.clockUntil = this.store.cacheUntil = loaded.cacheUntil;
|
|
1677
1792
|
this.agent.messages = loaded.messages;
|
|
1678
1793
|
this.agent.restoreFileState(loaded.messages, loaded.times);
|
|
1679
1794
|
this.agent.lastActivityAt = loaded.lastAt; // a long pause before this turn clears old tool results, as it would have
|
|
@@ -1711,6 +1826,7 @@ export class App {
|
|
|
1711
1826
|
this.topic = null;
|
|
1712
1827
|
this.customTitle = null;
|
|
1713
1828
|
this.firstPrompt = null;
|
|
1829
|
+
this.cost = 0;
|
|
1714
1830
|
this.remote?.resync();
|
|
1715
1831
|
}
|
|
1716
1832
|
|
|
@@ -1987,7 +2103,10 @@ export class App {
|
|
|
1987
2103
|
messageAdded(msg, agent) { if (this.isMain(agent)) this.store.message(msg); }
|
|
1988
2104
|
systemPrompt(text, agent) { if (this.isMain(agent)) this.store.setSystemPrompt(text); }
|
|
1989
2105
|
messagesReplaced(msgs, agent) { if (this.isMain(agent)) this.store.reset(msgs); }
|
|
1990
|
-
usage() {
|
|
2106
|
+
usage(_u, agent) {
|
|
2107
|
+
if (this.isMain(agent)) this.store.setCacheUntil(agent.cache.clockUntil); // the clock of a resumed conversation
|
|
2108
|
+
this.screen.schedule();
|
|
2109
|
+
}
|
|
1991
2110
|
|
|
1992
2111
|
compactStart(auto, agent) {
|
|
1993
2112
|
if (!this.isMain(agent)) return;
|
|
@@ -2008,6 +2127,7 @@ export class App {
|
|
|
2008
2127
|
this.status = null;
|
|
2009
2128
|
if (err) return;
|
|
2010
2129
|
this.lastSummary = summary;
|
|
2130
|
+
this.store.setCacheUntil(agent.cache.clockUntil);
|
|
2011
2131
|
const what = part === 'before' ? 'The earlier part of the conversation was summarized' : part === 'after' ? 'The conversation from that message on was summarized' : 'Conversation compacted';
|
|
2012
2132
|
this.screen.log(c.dim(`✻ ${what} (the summary is in context; /context to see usage)`), '');
|
|
2013
2133
|
}
|
|
@@ -2120,11 +2240,12 @@ export class App {
|
|
|
2120
2240
|
}
|
|
2121
2241
|
if (lines.length) lines.push('');
|
|
2122
2242
|
const placeholder = this.busy ? 'Type a message to queue it for the agent…' : (this.agent.messages.length ? '' : 'Try "explain this codebase" or "fix the failing test"');
|
|
2123
|
-
const r = this.editor.render(width, { placeholder, title: this.boxTitle() });
|
|
2243
|
+
const r = this.finder ? this.renderFind(width) : this.editor.render(width, { placeholder, title: this.boxTitle() });
|
|
2124
2244
|
const top = lines.length;
|
|
2125
2245
|
lines.push(...r.lines);
|
|
2126
2246
|
if (r.cursor) cursor = { row: top + r.cursor.row, col: r.cursor.col };
|
|
2127
|
-
if (this.
|
|
2247
|
+
if (this.finder) lines.push(c.dim(truncate(' ⏎ or ↑ older · shift+⏎ or ↓ newer · PgUp/PgDn scroll · esc to close', w)));
|
|
2248
|
+
else if (this.editor.suggestions) lines.push(...this.editor.renderSuggestions(w));
|
|
2128
2249
|
else if (this.editor.text === '?') lines.push(...this.shortcutsHelp());
|
|
2129
2250
|
else lines.push(this.footer(w));
|
|
2130
2251
|
return { lines, cursor };
|
|
@@ -2140,6 +2261,15 @@ export class App {
|
|
|
2140
2261
|
return { left: shown.replace(/\\/g, '/'), right: name && this.remote?.online ? `⇄ ${name}` : name };
|
|
2141
2262
|
}
|
|
2142
2263
|
|
|
2264
|
+
// The prompt cache clock, as in Claude Code's VS Code extension: the minutes the conversation's cache has left
|
|
2265
|
+
// ("◷ 12m"), or the icon alone in red once it has run out or right after a compaction, until the next response:
|
|
2266
|
+
// the next message then writes the whole conversation to the cache again. Nothing before a response reported
|
|
2267
|
+
// cache use.
|
|
2268
|
+
cacheClock() {
|
|
2269
|
+
const clock = this.agent?.cache.clock();
|
|
2270
|
+
return !clock ? '' : clock.cold ? c.red('◷') : c.dim(`◷ ${clock.minutes}m`);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2143
2273
|
footer(w) {
|
|
2144
2274
|
const mode = this.runtime.modes.mode;
|
|
2145
2275
|
let left;
|
|
@@ -2174,8 +2304,13 @@ export class App {
|
|
|
2174
2304
|
// An effort that is not set is not sent: the API then uses the model's default, which is shown instead.
|
|
2175
2305
|
const level = effort || defaultEffort(cfg.mainModel.id, cfg);
|
|
2176
2306
|
const info = c.dim(level ? `${model} · effort ${level}` : model);
|
|
2307
|
+
// Before the model: the prompt cache clock, then what the conversation has cost so far; when room is short
|
|
2308
|
+
// the cost goes first, then the clock.
|
|
2309
|
+
const spent = this.cost > 0 ? c.dim(formatUsd(this.cost)) : '';
|
|
2310
|
+
const clock = this.cacheClock();
|
|
2311
|
+
const join = (...parts) => parts.filter(Boolean).join(c.dim(' · '));
|
|
2177
2312
|
const room = w - stringWidth(left) - 1;
|
|
2178
|
-
const right = [notice
|
|
2313
|
+
const right = [join(notice, clock, spent, info), join(notice, clock, info), join(notice, info), notice, info].find((r) => r && stringWidth(r) < room) || '';
|
|
2179
2314
|
const gap = room - stringWidth(right);
|
|
2180
2315
|
return right ? left + ' '.repeat(gap) + right : left;
|
|
2181
2316
|
}
|
package/src/ui/screen.js
CHANGED
|
@@ -43,6 +43,10 @@ const FULL_KEEP = 20_000; // fullscreen: lines of the conversation kept to scrol
|
|
|
43
43
|
const ALT_ON = '\x1b[?1049h\x1b[?1002h\x1b[?1006h';
|
|
44
44
|
const ALT_OFF = '\x1b[?1002l\x1b[?1006l\x1b[?1049l';
|
|
45
45
|
const MULTI_CLICK_MS = 450;
|
|
46
|
+
// A search (ctrl+f): the matches, the current one, and how many are looked for at most.
|
|
47
|
+
const FIND_MATCH = '\x1b[30;43m';
|
|
48
|
+
const FIND_CURRENT = '\x1b[30;48;5;208m';
|
|
49
|
+
const FIND_MAX = 10_000;
|
|
46
50
|
|
|
47
51
|
// The part of `plain` (no escape codes) between columns `from` (included) and `to` (not).
|
|
48
52
|
function sliceCols(plain, from, to) {
|
|
@@ -146,6 +150,8 @@ export class Screen {
|
|
|
146
150
|
this.lastArea = 0; // fullscreen: rows the conversation had on screen at the last draw
|
|
147
151
|
this.viewMap = []; // fullscreen: what each row of the conversation area shows, {line, sub} (null: blank)
|
|
148
152
|
this.barRow = null; // fullscreen: the row of the "Jump to bottom" bar, when it is shown
|
|
153
|
+
this.finding = null; // fullscreen: a search in the conversation (ctrl+f), see openFind
|
|
154
|
+
this.dropped = 0; // fullscreen: lines dropped from the top of the history so far (the first one's number)
|
|
149
155
|
this.selection = null; // fullscreen: {anchor, focus}, points {line, sub, col} of the conversation (history line, wrapped row, column)
|
|
150
156
|
this.dragging = false;
|
|
151
157
|
this.lastClick = { at: 0, x: 0, y: 0, count: 0 };
|
|
@@ -270,11 +276,163 @@ export class Screen {
|
|
|
270
276
|
return to > from ? [from, to] : null;
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
// A row of the conversation as drawn: the selection in reverse video, the matches of a search highlighted (the
|
|
280
|
+
// current one apart). Such a row loses its own colours.
|
|
273
281
|
paintRow(e) {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
const sel = this.selectedCols(e);
|
|
283
|
+
const hits = this.finding?.byRow.get(`${e.line}:${e.sub}`);
|
|
284
|
+
if (!sel && !hits) return e.text;
|
|
285
|
+
const style = (col) => {
|
|
286
|
+
if (sel && col >= sel[0] && col < sel[1]) return '\x1b[7m';
|
|
287
|
+
const hit = hits?.find((h) => col >= h.from && col < h.to);
|
|
288
|
+
return !hit ? '' : hit.match === this.finding.matches[this.finding.index] ? FIND_CURRENT : FIND_MATCH;
|
|
289
|
+
};
|
|
290
|
+
let out = '';
|
|
291
|
+
let open = '';
|
|
292
|
+
let col = 0;
|
|
293
|
+
for (const g of graphemes(stripAnsi(e.text))) {
|
|
294
|
+
const s = style(col);
|
|
295
|
+
if (s !== open) { out += '\x1b[0m' + s; open = s; }
|
|
296
|
+
out += g;
|
|
297
|
+
col += graphemeWidth(g);
|
|
298
|
+
}
|
|
299
|
+
return out + (open ? '\x1b[0m' : '');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ------------------------------------------------------------ finding text (fullscreen, ctrl+f)
|
|
303
|
+
//
|
|
304
|
+
// `finding`: {query, matches, byRow, index, anchor, key}. Matches are newest first (from the bottom of the
|
|
305
|
+
// conversation up): {abs, offset, line, sub, col, fromEnd}, `abs` the line counted from the first line ever
|
|
306
|
+
// printed (so that it survives lines dropped from the top), `offset` where it starts in the line's text, and
|
|
307
|
+
// `fromEnd` the rows from its first row to the end of the conversation. `byRow` maps "line:sub" to the column
|
|
308
|
+
// ranges to highlight, {from, to, match}. A match may go on over the rows a long line was wrapped onto.
|
|
309
|
+
|
|
310
|
+
// Start finding: the first match shown is the nearest one above the bottom of the view as it is now.
|
|
311
|
+
openFind() {
|
|
312
|
+
this.takeStatic();
|
|
313
|
+
this.finding = { query: '', matches: [], byRow: new Map(), index: -1, anchor: this.scroll, cols: 0, dropped: -1, scanned: -1 };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
closeFind() {
|
|
317
|
+
if (!this.finding) return;
|
|
318
|
+
this.finding = null;
|
|
319
|
+
this.draw();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Look for `query` as typed, in any case; shows the first match (see openFind). {index, total}: index is 0 for
|
|
323
|
+
// the newest match.
|
|
324
|
+
setFind(query) {
|
|
325
|
+
const f = this.finding;
|
|
326
|
+
if (!f) return null;
|
|
327
|
+
f.query = query;
|
|
328
|
+
f.cols = 0; // all of it again
|
|
329
|
+
f.index = -1;
|
|
330
|
+
this.refreshFind();
|
|
331
|
+
if (f.matches.length) {
|
|
332
|
+
const at = f.matches.findIndex((m) => m.fromEnd >= f.anchor);
|
|
333
|
+
f.index = at >= 0 ? at : f.matches.length - 1;
|
|
334
|
+
this.revealMatch();
|
|
335
|
+
}
|
|
336
|
+
this.draw();
|
|
337
|
+
return this.findStatus();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The next match up (`dir` 1: older) or down (-1: newer), round from either end.
|
|
341
|
+
findStep(dir) {
|
|
342
|
+
const f = this.finding;
|
|
343
|
+
if (!f) return null;
|
|
344
|
+
this.refreshFind();
|
|
345
|
+
const n = f.matches.length;
|
|
346
|
+
if (n) {
|
|
347
|
+
f.index = ((f.index < 0 ? 0 : f.index + dir) % n + n) % n;
|
|
348
|
+
this.revealMatch();
|
|
349
|
+
}
|
|
350
|
+
this.draw();
|
|
351
|
+
return this.findStatus();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
findStatus() {
|
|
355
|
+
const f = this.finding;
|
|
356
|
+
if (!f) return null;
|
|
357
|
+
this.refreshFind();
|
|
358
|
+
return { query: f.query, index: f.index, total: f.matches.length };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The matches again when the conversation or the width changed since they were found; the current one stays.
|
|
362
|
+
// Lines added at the end (output while the search is open) are searched on their own.
|
|
363
|
+
refreshFind() {
|
|
364
|
+
const f = this.finding;
|
|
365
|
+
const cols = this.width;
|
|
366
|
+
if (!f || (f.cols === cols && f.dropped === this.dropped && f.scanned === this.history.length)) return;
|
|
367
|
+
if (!f.query) {
|
|
368
|
+
Object.assign(f, { matches: [], byRow: new Map(), index: -1, cols, dropped: this.dropped, scanned: this.history.length });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (f.cols === cols && f.dropped === this.dropped && f.scanned < this.history.length) {
|
|
372
|
+
const added = this.scanFind(f, f.scanned, this.history.length, cols);
|
|
373
|
+
for (const m of f.matches) m.fromEnd += added.rows;
|
|
374
|
+
f.matches = [...added.matches, ...f.matches];
|
|
375
|
+
if (f.index >= 0) f.index += added.matches.length;
|
|
376
|
+
f.scanned = this.history.length;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const current = f.matches[f.index];
|
|
380
|
+
f.byRow = new Map();
|
|
381
|
+
f.matches = this.scanFind(f, 0, this.history.length, cols).matches;
|
|
382
|
+
Object.assign(f, { cols, dropped: this.dropped, scanned: this.history.length });
|
|
383
|
+
const same = current ? f.matches.findIndex((m) => m.abs === current.abs && m.offset === current.offset) : -1;
|
|
384
|
+
f.index = same >= 0 ? same : Math.min(f.index, f.matches.length - 1);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The matches of f.query in history lines [from, to), newest first, entered in f.byRow; `rows`: the rows those
|
|
388
|
+
// lines take (fromEnd counts from the end of line to - 1).
|
|
389
|
+
scanFind(f, from, to, cols) {
|
|
390
|
+
const re = new RegExp(f.query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'giu');
|
|
391
|
+
const matches = [];
|
|
392
|
+
let rowsAfter = 0;
|
|
393
|
+
for (let line = to - 1; line >= from; line--) {
|
|
394
|
+
const rows = this.wrappedAt(line, cols);
|
|
395
|
+
if (matches.length < FIND_MAX) {
|
|
396
|
+
const plains = rows.map((r) => stripAnsi(r));
|
|
397
|
+
const text = plains.join('');
|
|
398
|
+
const starts = [];
|
|
399
|
+
for (let k = 0, at = 0; k < plains.length; at += plains[k].length, k++) starts.push(at);
|
|
400
|
+
// offset -> {sub, col}: the row it falls on, and the column there (`end`: an end offset belongs to the row before)
|
|
401
|
+
const place = (offset, end = false) => {
|
|
402
|
+
let sub = plains.length - 1;
|
|
403
|
+
while (sub > 0 && (end ? starts[sub] >= offset : starts[sub] > offset)) sub--;
|
|
404
|
+
return { sub, col: stringWidth(plains[sub].slice(0, offset - starts[sub])) };
|
|
405
|
+
};
|
|
406
|
+
const found = [];
|
|
407
|
+
for (const m of text.matchAll(re)) {
|
|
408
|
+
if (!m[0]) continue;
|
|
409
|
+
const a = place(m.index);
|
|
410
|
+
const b = place(m.index + m[0].length, true);
|
|
411
|
+
const match = { abs: this.dropped + line, offset: m.index, line, sub: a.sub, col: a.col, fromEnd: rowsAfter + rows.length - 1 - a.sub };
|
|
412
|
+
for (let sub = a.sub; sub <= b.sub; sub++) {
|
|
413
|
+
const k = `${line}:${sub}`;
|
|
414
|
+
if (!f.byRow.has(k)) f.byRow.set(k, []);
|
|
415
|
+
f.byRow.get(k).push({ from: sub === a.sub ? a.col : 0, to: sub === b.sub ? b.col : Infinity, match });
|
|
416
|
+
}
|
|
417
|
+
found.push(match);
|
|
418
|
+
}
|
|
419
|
+
matches.push(...found.reverse());
|
|
420
|
+
}
|
|
421
|
+
rowsAfter += rows.length;
|
|
422
|
+
}
|
|
423
|
+
return { matches, rows: rowsAfter };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Scroll so that the current match is on screen: where it is if it is already in view, else in the middle.
|
|
427
|
+
revealMatch() {
|
|
428
|
+
const m = this.finding?.matches[this.finding.index];
|
|
429
|
+
if (!m || !this.full) return;
|
|
430
|
+
const area = Math.max(1, this.lastArea || this.height - 6);
|
|
431
|
+
const lowest = this.scroll + (this.scroll > 0 ? 1 : 0); // the "Jump to bottom" bar covers the last row
|
|
432
|
+
if (m.fromEnd >= lowest && m.fromEnd < this.scroll + area) return;
|
|
433
|
+
const max = Math.max(0, this.totalRows(this.width) - area);
|
|
434
|
+
this.scroll = Math.max(0, Math.min(max, m.fromEnd - Math.floor(area / 2)));
|
|
435
|
+
if (!this.scroll) this.unseen = 0;
|
|
278
436
|
}
|
|
279
437
|
|
|
280
438
|
// The text selected, as it was printed: rows a long line was wrapped onto are joined again.
|
|
@@ -407,6 +565,7 @@ export class Screen {
|
|
|
407
565
|
const layout = this.layoutLive(cols);
|
|
408
566
|
const area = Math.max(0, screenRows - layout.rows.length);
|
|
409
567
|
this.lastArea = area;
|
|
568
|
+
if (this.finding) this.refreshFind();
|
|
410
569
|
const shown = this.view(area, cols);
|
|
411
570
|
this.viewMap = shown;
|
|
412
571
|
const top = shown.map((e) => (e ? this.paintRow(e) : ''));
|
|
@@ -474,6 +633,7 @@ export class Screen {
|
|
|
474
633
|
const drop = this.history.length - keep;
|
|
475
634
|
this.history.splice(0, drop);
|
|
476
635
|
this.wrapped.splice(0, drop);
|
|
636
|
+
this.dropped += drop;
|
|
477
637
|
if (this.selection) {
|
|
478
638
|
for (const p of [this.selection.anchor, this.selection.focus]) p.line -= drop;
|
|
479
639
|
if (this.selection.anchor.line < 0 || this.selection.focus.line < 0) this.selection = null;
|
|
@@ -630,6 +790,7 @@ export class Screen {
|
|
|
630
790
|
|
|
631
791
|
clearScreen() {
|
|
632
792
|
if (this.full) {
|
|
793
|
+
this.dropped += this.history.length;
|
|
633
794
|
this.history = [];
|
|
634
795
|
this.wrapped = [];
|
|
635
796
|
this.selection = null;
|