Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

[English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md)

## Unreleased
## 1.8.0

- Added image/material cards, pinned versions, remarks, and handoff to an existing agent session. The handoff icon stays fixed in the card header, and the dialog places its Cancel/Send actions together at the bottom right (#123, #128, #130).
- Added usage history with measured quota changes, conditional session estimates, local-log token evidence, and source health. Five sections in the left sidebar replace the long report: conditional estimate, quotas, agents, local-log tokens, and source state. Estimates remain explicitly distinct from measured usage (#138).
- Added interactive mascots and a Codex workflow for creating them (#139).
- Added per-agent default launch profiles and opening terminal file links in VS Code (#127, #129).
- Fixed macOS text-field clipboard/edit menus, OpenCode plugin exports, and native PTY file-descriptor leaks (#126, #131, #140).
- Fixed minimap navigation by fitting the camera viewport and all canvas objects together, keeping the camera outline inside the overview.
- Fixed usage collection creating WAL side files beside stopped Hermes databases, and blocked symlinked usage-history files on Windows. Completed the lockfile for clean npm 10 installs.
- Thanks to @tab11pm, @SaneSanders, @BIackFIame, @pepechka-kun, @CLOSETTTY, @teo-nex, @Yt4aZaveta, and @theorangecolorv2. Original contributor commits are preserved in the merge history.

- Added a rich file viewer to the **Files** card: Markdown files (`.md`, `.markdown`, `.mdown`, `.mkd`) render as formatted GitHub-Flavored Markdown with headings, lists, task lists, tables, blockquotes, horizontal rules, inline code, and fenced code blocks with syntax highlighting, recognized code files are syntax-highlighted by the language detected from their extension while unknown languages stay plain monospaced text, all rendered content is sanitized so raw HTML, scripts, event-handler attributes, and dangerous link schemes never render, `http(s)` links open in the app's built-in browser with other schemes inert, only embedded `data:` images show while remote or relative sources fall back to alt text or a placeholder without a network request, and text and code files above the rich-rendering size threshold fall back to plain text with a notice.
- Added a read-only **Files** canvas card and reader: open it from the empty-canvas context menu or the `Cmd/Ctrl+K` palette, root it at a live terminal session working directory or a folder picked in a native dialog, browse a directory tree that loads on demand, and read text/code as selectable monospace (bounded at 2 MB with a truncation marker and total size) or supported images (bounded at 25 MB) with explicit binary, unsupported, and too-large states. Quick open searches file names inside the active root, each card's root, bounds, open file, and expanded folders persist and restore with an unavailable state when the root is gone, and a new main-process `FileAccessService` enforces `realpath` containment so every listing, read, and search uses relative paths strictly inside registered roots with no write, rename, or delete capability.
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

[English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md)

## 1.8.0

- Добавлены карточки изображений и материалов, закреплённые версии, замечания и передача действующей сессии агента. Иконка передачи неподвижна в заголовке; «Отмена» и «Отправить» расположены вместе внизу справа (#123, #128, #130).
- Добавлены файловые карточки с деревом каталогов и просмотром Markdown, кода, текста и изображений без редактирования файлов (#125).
- Добавлена история использования: изменения квот, условные оценки по сессиям, токены из локальных логов и состояние источников. Пять разделов слева заменяют длинный отчёт; оценки явно отделены от измерений (#138).
- Добавлены интерактивные маскоты и их создание через Codex (#139).
- Добавлены настройки запуска по умолчанию для каждого агента и открытие файловых ссылок терминала в VS Code (#127, #129).
- Исправлены меню буфера обмена macOS, экспорт плагина OpenCode и утечки файловых дескрипторов PTY на macOS (#126, #131, #140).
- Миникарта теперь вмещает камеру и все объекты вместе, не выпуская белую рамку за границы обзора.
- Чтение остановленной базы Hermes больше не создаёт WAL-файлы; чтение истории по символической ссылке заблокировано и на Windows. Исправлен lockfile для чистой установки через npm 10.

## 1.7.1

- Добавлены обновления стабильных версий из приложения: загрузка и установка по запросу, подтверждение остановки активных сессий и подписанные обновления Sparkle на macOS. Первую версию с этой функцией нужно установить вручную; последующие совместимые версии можно устанавливать из приложения.
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

[English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md)

## 1.8.0

- 新增图片与材料卡片、固定版本、批注及向现有智能体会话发送批注。发送图标固定在卡片标题栏,取消与发送按钮并排置于对话框右下角(#123、#128、#130)。
- 新增只读文件卡片、目录树及 Markdown、代码、文本和图片预览(#125)。
- 新增使用历史:额度变化、会话条件估算、本地日志 token 证据及来源状态。左侧五个分区替代长报告,估算与实际测量明确区分(#138)。
- 新增交互式吉祥物及通过 Codex 创建吉祥物的流程(#139)。
- 新增各智能体的默认启动配置及在 VS Code 中打开终端文件链接(#127、#129)。
- 修复 macOS 剪贴板菜单、OpenCode 插件导出及 macOS PTY 文件描述符泄漏(#126、#131、#140)。
- 小地图同时容纳镜头视口与所有画布对象,白色边框保持在概览范围内。
- 读取已停止的 Hermes 数据库不再创建 WAL 文件;Windows 同样拒绝通过符号链接读取使用历史。补全 npm 10 全新安装所需的 lockfile。

## 1.7.1

- 新增应用内稳定版本更新:由用户下载和安装,停止活动会话前确认,macOS 使用 Sparkle 签名更新。首个支持更新的版本需要手动安装;后续兼容版本可在应用内安装。
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "canvastty",
"version": "1.7.1",
"version": "1.8.0",
"private": true,
"description": "A spatial desktop for local AI agents and real terminal sessions.",
"desktopName": "CanvasTTY",
Expand Down
2 changes: 2 additions & 0 deletions src/main/services/LocalUsageCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ export class LocalUsageCollector {
const target = (query: string): string => `${pathToFileURL(database).href}?${query}`;
const [wal, shm] = await Promise.all([sideFile(`${database}-wal`), sideFile(`${database}-shm`)]);
if (shm === null && wal) throw new SourceProblem('WAL without shared-memory index (writer stopped uncleanly); skipped to avoid a stale read');
// A read-only WAL connection can still create side files beside a stopped writer.
if (shm === null && !wal) return await this.query(target('mode=ro&immutable=1'), sql);
try {
return await this.query(target('mode=ro&readonly_shm=1'), sql);
} catch (error) {
Expand Down
12 changes: 9 additions & 3 deletions src/main/services/UsageHistoryService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, open, rename, unlink, type FileHandle } from "node:fs/promises";
import { copyFile, lstat, open, rename, unlink, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { LimitProviderId, LimitsSnapshot } from "../../shared/contracts.ts";
Expand Down Expand Up @@ -117,9 +117,15 @@ export class UsageHistoryService {
let raw: string;
let handle: FileHandle | undefined;
try {
// O_NOFOLLOW: a planted symlink is neither read nor, later, written through.
// Windows lacks O_NOFOLLOW; check the path before and after opening too.
if ((await lstat(this.filePath)).isSymbolicLink()) throw Object.assign(new Error("symlink"), { code: "ELOOP" });
handle = await open(this.filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
if (!(await handle.stat()).isFile()) throw Object.assign(new Error("not a file"), { code: "ENOTFILE" });
const opened = await handle.stat();
const current = await lstat(this.filePath);
if (current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) {
throw Object.assign(new Error("history path changed"), { code: "ELOOP" });
}
if (!opened.isFile()) throw Object.assign(new Error("not a file"), { code: "ENOTFILE" });
raw = await handle.readFile("utf8");
} catch (error) {
if (errorCode(error) === "ENOENT") return;
Expand Down
9 changes: 7 additions & 2 deletions tests/usage-account-scope.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ function registry(executables) {
state: "available",
provider,
executable,
launcher: "native",
launcher: process.platform === "win32" ? "batch" : "native",
commandPrompt: process.env.ComSpec,
environment: Object.freeze({ PATH: process.env.PATH ?? "/usr/bin:/bin" }),
checked: Object.freeze([])
})
Expand Down Expand Up @@ -78,9 +79,13 @@ createInterface({ input: process.stdin }).on("line", (line) => {
});
`);
await chmod(script, 0o755);
const executable = process.platform === "win32" ? join(root, "fake-codex.cmd") : script;
if (process.platform === "win32") {
await writeFile(executable, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`);
}
return {
codexHome,
script,
script: executable,
spawns: async () => (await readFile(join(root, "spawns"), "utf8").catch(() => "")).split("\n").filter(Boolean).length
};
}
Expand Down
2 changes: 1 addition & 1 deletion tests/usage-attribution-integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ test("a read backlog defers estimates; the next complete run supplies the late e
await h.service.dispose();
});

test("recorded health: an unreadable log blocks estimates until read; a lost record blocks only clusters it may have held", { skip: process.getuid?.() === 0 }, async (t) => {
test("recorded health: an unreadable log blocks estimates until read; a lost record blocks only clusters it may have held", { skip: process.platform === "win32" || process.getuid?.() === 0 }, async (t) => {
const home = await temporary(t, "usage-int-home-");
const directory = await temporary(t, "usage-int-data-");
const ids = await writeLogs(home);
Expand Down
10 changes: 5 additions & 5 deletions tests/usage-collector.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ test('Hermes queries select accounting columns only, read-only, and never touch
}).collect(T0);
const statements = argumentsSeen.filter((argument) => /^\s*SELECT/i.test(argument));
assert.equal(statements.length, 2);
assert.ok(argumentsSeen.includes('-readonly') && argumentsSeen.includes('-safe') && argumentsSeen.some((a) => /^file:.*\?mode=ro&readonly_shm=1$/.test(a)));
assert.ok(argumentsSeen.includes('-readonly') && argumentsSeen.includes('-safe') && argumentsSeen.some((a) => /^file:.*\?mode=ro&immutable=1$/.test(a)));
for (const statement of statements) {
assert.doesNotMatch(statement, /\*|system_prompt|title|messages|content|INSERT|UPDATE|DELETE|CREATE|DROP|ATTACH|PRAGMA\s/i);
}
Expand Down Expand Up @@ -467,9 +467,9 @@ test('Collector: byte budget resumes backlog, overlapping calls do not duplicate
const results = await Promise.all([concurrent.collect(NOW), concurrent.collect(NOW), concurrent.collect(NOW)]);
assert.deepEqual(results.flatMap((r) => r.events.map((e) => e.id)), reference);

if (process.getuid?.() !== 0) {
const locked = rollout(home, randomUUID());
await write(locked, [codexMeta('locked', start), ...lineage(start, [[5, 0, 1]])]);
const locked = rollout(home, randomUUID());
await write(locked, [codexMeta('locked', start), ...lineage(start, [[5, 0, 1]])]);
if (process.platform !== 'win32' && process.getuid?.() !== 0) {
await chmod(locked, 0);
const blocked = await concurrent.collect(NOW);
assert.deepEqual(blocked.events, []);
Expand Down Expand Up @@ -523,7 +523,7 @@ test('Collector health: complete only when every discovered log was read to its
const after = await collector.collect(NOW);
assert.deepEqual([after.complete, after.lost], [true, false], 'a loss is reported once, by the run that consumed it');

if (process.getuid?.() !== 0) {
if (process.platform !== 'win32' && process.getuid?.() !== 0) {
const locked = rollout(home, randomUUID());
await write(locked, [codexMeta('locked', start), ...lineage(start, [[5, 0, 1]])]);
await chmod(locked, 0);
Expand Down
4 changes: 2 additions & 2 deletions tests/usage-history-service.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ test("first collection creates a private, atomically replaced history file with
assert.deepEqual(limits.calls, [["codex", "claude"]]);

assert.deepEqual(await readdir(directory), [FILE], "no temporary files remain");
assert.equal((await stat(join(directory, FILE))).mode & 0o777, 0o600);
if (process.platform !== "win32") assert.equal((await stat(join(directory, FILE))).mode & 0o777, 0o600);
const persisted = JSON.parse(await readFile(join(directory, FILE), "utf8"));
assert.deepEqual(persisted.history.samples, collected.samples);
assert.equal(JSON.stringify(history.get()).includes("\"files\""), false, "collector cursors stay private to the main process");
Expand Down Expand Up @@ -431,7 +431,7 @@ test("a symlinked history file is neither followed nor replaced", async () => {
});
});

test("save failures are surfaced without losing the in-memory history", { skip: process.getuid?.() === 0 }, async () => {
test("save failures are surfaced without losing the in-memory history", { skip: process.platform === "win32" || process.getuid?.() === 0 }, async () => {
await withDirectory(async (directory) => {
const store = join(directory, "store");
await mkdir(store);
Expand Down
5 changes: 3 additions & 2 deletions tests/usage-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -298,12 +298,13 @@ test("renders in English when the app locale is English", () => {
});

test("App mounts the trigger beside the title bar and portals the overlay outside the canvas", async () => {
const app = await source("src/renderer/src/App.tsx");
const app = (await source("src/renderer/src/App.tsx")).replaceAll("\r\n", "\n");
assert.match(app, /import \{ UsageHistoryPanel \} from "\.\/features\/usage\/UsageHistoryPanel";/);
assert.match(app, /<div ref=\{setOverlayHost\} className=\{rootClasses\}/);
assert.match(app, /<TitleBar[^\n]*\/>\n\s*<UsageHistoryPanel\n\s*locale=\{settings\.locale\}\n\s*open=\{usageHistoryOpen\}\n\s*container=\{overlayHost\}/);
assert.match(app, /browserViewVisible=\{!settingsOpen && !usageHistoryOpen && /);
assert.equal((app.match(/if \(usageHistoryOpen\) return;/g) ?? []).length, 2);
assert.match(app, /if \(usageHistoryOpen\) return;/);
assert.match(app, /if \(shortcutReferenceOpen \|\| usageHistoryOpen \|\| handoffRemarkIds !== null\) return;/);
const workspaceIndex = app.indexOf("<WorkspaceCanvas");
assert.ok(app.indexOf("<UsageHistoryPanel") < workspaceIndex, "not inside the transformed canvas");
});
Expand Down
Loading