pi-open-tui 0.3.14 → 0.4.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 +72 -5
- package/README.zh-CN.md +72 -5
- package/assets/preview_dashboard_1.png +0 -0
- package/extensions/open-tui/config.ts +26 -2
- package/extensions/open-tui/copy-buttons.ts +363 -0
- package/extensions/open-tui/editor.ts +322 -122
- package/extensions/open-tui/footer.ts +13 -11
- package/extensions/open-tui/git.ts +10 -32
- package/extensions/open-tui/header.ts +133 -244
- package/extensions/open-tui/icons.ts +9 -0
- package/extensions/open-tui/index.ts +45 -20
- package/extensions/open-tui/runtime.ts +10 -11
- package/extensions/open-tui/session-lifecycle.ts +23 -43
- package/extensions/open-tui/settings-command.ts +66 -45
- package/extensions/open-tui/state.ts +11 -14
- package/extensions/open-tui/tool-preview.ts +167 -0
- package/extensions/open-tui/tool-view.ts +418 -0
- package/extensions/open-tui/utils.ts +3 -3
- package/package.json +11 -15
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
|
|
3
|
+
import { installCopyButtons, markCopyScope } from "./copy-buttons.ts";
|
|
3
4
|
import { installEditor } from "./editor.ts";
|
|
4
5
|
import { installFooter } from "./footer.ts";
|
|
5
6
|
import { installHeader } from "./header.ts";
|
|
@@ -8,6 +9,7 @@ import { readRuntimeInfo } from "./runtime.ts";
|
|
|
8
9
|
import { SessionLifecycle } from "./session-lifecycle.ts";
|
|
9
10
|
import { registerSettingsCommand } from "./settings-command.ts";
|
|
10
11
|
import { formatTurnTelemetry, TurnTelemetryTracker } from "./telemetry.ts";
|
|
12
|
+
import { registerToolView } from "./tool-view.ts";
|
|
11
13
|
import {
|
|
12
14
|
createInitialState,
|
|
13
15
|
getModelMeta,
|
|
@@ -31,12 +33,7 @@ export function getPendingUiChange(enabled: boolean, active: boolean): PendingUi
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
function isTuiContext(ctx: ExtensionContext): boolean {
|
|
34
|
-
|
|
35
|
-
const mode = (ctx as ExtensionContext & { mode?: string }).mode;
|
|
36
|
-
return ctx.hasUI && (mode === undefined || mode === "tui");
|
|
37
|
-
} catch {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
36
|
+
return ctx.hasUI && ctx.mode === "tui";
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
export default function (pi: ExtensionAPI) {
|
|
@@ -45,6 +42,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
45
42
|
const turnTelemetry = new TurnTelemetryTracker();
|
|
46
43
|
|
|
47
44
|
let config: OpenTuiConfig = structuredClone(DEFAULT_CONFIG);
|
|
45
|
+
let configLoaded = false;
|
|
48
46
|
let active = false;
|
|
49
47
|
let lastCtx: ExtensionContext | undefined;
|
|
50
48
|
let requestFooterRender: (() => void) | undefined;
|
|
@@ -52,6 +50,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
52
50
|
let cleanupHeader: (() => void) | undefined;
|
|
53
51
|
let cleanupFooter: (() => void) | undefined;
|
|
54
52
|
let editor: ReturnType<typeof installEditor> | undefined;
|
|
53
|
+
let copyButtons: ReturnType<typeof installCopyButtons> | undefined;
|
|
55
54
|
let pendingUiChange: PendingUiChange | undefined;
|
|
56
55
|
|
|
57
56
|
// thinking-peek state (rendered by Pi inside its hidden-thinking block)
|
|
@@ -63,15 +62,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
63
62
|
|
|
64
63
|
const getThinkingLevel = () => (sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : "off");
|
|
65
64
|
|
|
66
|
-
/** True while the agent is running (false during session-restore replay). */
|
|
67
|
-
const isAgentIdle = (ctx: ExtensionContext): boolean => {
|
|
68
|
-
try {
|
|
69
|
-
return (ctx as ExtensionContext & { isIdle?: () => boolean }).isIdle?.() === true;
|
|
70
|
-
} catch {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
65
|
/** Pi decides whether this label is visible; our toggle only controls updates. */
|
|
76
66
|
const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
|
|
77
67
|
|
|
@@ -114,7 +104,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
114
104
|
return;
|
|
115
105
|
}
|
|
116
106
|
if (!active) {
|
|
117
|
-
|
|
107
|
+
// Leave the header slot alone when disabled so another extension's header survives.
|
|
108
|
+
if (config.header) cleanupHeader = installHeader(pi, ctx);
|
|
118
109
|
const footer = installFooter(
|
|
119
110
|
ctx,
|
|
120
111
|
() => state,
|
|
@@ -128,6 +119,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
128
119
|
void scheduleGitRefresh(ctx);
|
|
129
120
|
},
|
|
130
121
|
getLiveTps: () => turnTelemetry.getLiveTps(),
|
|
122
|
+
canEmbedInEditor: () => editor?.isFramed() ?? false,
|
|
131
123
|
},
|
|
132
124
|
);
|
|
133
125
|
cleanupFooter = footer.cleanup;
|
|
@@ -140,6 +132,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
140
132
|
render: footer.renderInline,
|
|
141
133
|
},
|
|
142
134
|
);
|
|
135
|
+
copyButtons = installCopyButtons({
|
|
136
|
+
getTui: editor.getTui,
|
|
137
|
+
getTheme: () => ctx.ui.theme,
|
|
138
|
+
isEnabled: () => config.copyButtons,
|
|
139
|
+
});
|
|
143
140
|
active = true;
|
|
144
141
|
}
|
|
145
142
|
};
|
|
@@ -149,9 +146,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
149
146
|
if (active) {
|
|
150
147
|
cleanupHeader?.();
|
|
151
148
|
cleanupFooter?.();
|
|
149
|
+
copyButtons?.cleanup();
|
|
152
150
|
editor?.cleanup();
|
|
153
151
|
cleanupHeader = undefined;
|
|
154
152
|
cleanupFooter = undefined;
|
|
153
|
+
copyButtons = undefined;
|
|
155
154
|
editor = undefined;
|
|
156
155
|
requestFooterRender = undefined;
|
|
157
156
|
resetPeek();
|
|
@@ -221,13 +220,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
221
220
|
pi.on("session_start", async (_event, ctx) => {
|
|
222
221
|
sessionLifecycle.start();
|
|
223
222
|
lastCtx = ctx;
|
|
224
|
-
state.sessionStartEpoch = Date.now();
|
|
225
223
|
state.workingSince = undefined;
|
|
226
224
|
state.lastDoneIn = undefined;
|
|
227
225
|
invalidateUsageCache();
|
|
228
226
|
|
|
229
227
|
ensureConfigExists();
|
|
230
228
|
config = loadConfig((msg, level) => ctx.ui.notify(msg, level));
|
|
229
|
+
configLoaded = true;
|
|
231
230
|
clearPeekLabel(ctx);
|
|
232
231
|
|
|
233
232
|
applyUi(ctx);
|
|
@@ -271,7 +270,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
271
270
|
|
|
272
271
|
pi.on("message_start", (event, ctx) => {
|
|
273
272
|
turnTelemetry.handle(event);
|
|
274
|
-
if (!sessionLifecycle.isCurrent() ||
|
|
273
|
+
if (!sessionLifecycle.isCurrent() || ctx.isIdle()) return;
|
|
275
274
|
const role = event.message?.role;
|
|
276
275
|
if (role === "user") {
|
|
277
276
|
// Restore Pi's native label until real thinking arrives. The task epoch is
|
|
@@ -286,7 +285,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
286
285
|
|
|
287
286
|
pi.on("message_update", (event, ctx) => {
|
|
288
287
|
turnTelemetry.handle(event);
|
|
289
|
-
if (!sessionLifecycle.isCurrent() ||
|
|
288
|
+
if (!sessionLifecycle.isCurrent() || ctx.isIdle()) return;
|
|
290
289
|
if (!isPeekEnabled()) return;
|
|
291
290
|
const message = event.message;
|
|
292
291
|
if (!message || message.role !== "assistant") return;
|
|
@@ -366,12 +365,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
366
365
|
refreshInteractiveState(ctx);
|
|
367
366
|
});
|
|
368
367
|
|
|
368
|
+
registerToolView(
|
|
369
|
+
pi,
|
|
370
|
+
() => {
|
|
371
|
+
// Resumed sessions rebuild their rows before session_start fires, so read the file once here.
|
|
372
|
+
if (!configLoaded) {
|
|
373
|
+
config = loadConfig();
|
|
374
|
+
configLoaded = true;
|
|
375
|
+
}
|
|
376
|
+
return config.enabled ? config.toolView.mode : "native";
|
|
377
|
+
},
|
|
378
|
+
() => resolveGlyphs(config.icons.mode),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
pi.registerMarkdownTransformer(markCopyScope);
|
|
382
|
+
|
|
369
383
|
registerSettingsCommand(pi, {
|
|
370
384
|
getConfig: () => config,
|
|
371
385
|
onConfigChanged: (newConfig) => {
|
|
372
386
|
const cursorStyleChanged = config.cursorStyle !== newConfig.cursorStyle;
|
|
387
|
+
const headerChanged = config.header !== newConfig.header;
|
|
388
|
+
const copyButtonsChanged = config.copyButtons !== newConfig.copyButtons;
|
|
373
389
|
const thinkingPeekLinesChanged = config.thinkingPeek.lines !== newConfig.thinkingPeek.lines;
|
|
374
|
-
saveConfig(newConfig)
|
|
390
|
+
if (!saveConfig(newConfig)) {
|
|
391
|
+
lastCtx?.ui.notify(newConfig.settingsLanguage === "zh"
|
|
392
|
+
? "open-tui 设置本次生效,但未保存到磁盘。请检查配置目录权限和可用空间。"
|
|
393
|
+
: "open-tui settings apply for this session but could not be saved. Check configuration directory permissions and free space.", "warning");
|
|
394
|
+
}
|
|
375
395
|
config = newConfig;
|
|
376
396
|
if (newConfig.thinkingPeek.lines === 0 || !newConfig.enabled) {
|
|
377
397
|
resetPeek();
|
|
@@ -382,6 +402,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
382
402
|
if (cursorStyleChanged && active && editor) {
|
|
383
403
|
editor.setCursorStyle(newConfig.cursorStyle);
|
|
384
404
|
}
|
|
405
|
+
if (headerChanged && active && lastCtx) {
|
|
406
|
+
cleanupHeader?.();
|
|
407
|
+
cleanupHeader = newConfig.header ? installHeader(pi, lastCtx) : undefined;
|
|
408
|
+
}
|
|
409
|
+
if (copyButtonsChanged) copyButtons?.refresh();
|
|
385
410
|
if (lastCtx) {
|
|
386
411
|
pendingUiChange = getPendingUiChange(newConfig.enabled, active);
|
|
387
412
|
}
|
|
@@ -31,10 +31,9 @@ const RUNTIMES: readonly RuntimeDef[] = [
|
|
|
31
31
|
{ name: "swift", files: ["Package.swift"], versionCommand: { cmd: "swift", args: ["--version"], pattern: /Swift\s+(\d+\.\d+)/ } },
|
|
32
32
|
{ name: "kotlin", files: ["build.gradle.kts", "settings.gradle.kts"] },
|
|
33
33
|
{ name: "cpp", files: ["CMakeLists.txt", "Makefile"] },
|
|
34
|
-
{ name: "c", files: ["Makefile", "CMakeLists.txt"] },
|
|
35
34
|
{ name: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"], versionCommand: { cmd: "deno", args: ["--version"], pattern: /deno\s+(\d+\.\d+\.\d+)/ } },
|
|
36
35
|
{ name: "php", files: ["composer.json"], versionCommand: { cmd: "php", args: ["--version"], pattern: /PHP\s+(\d+\.\d+\.\d+)/ } },
|
|
37
|
-
{ name: "haskell", files: ["stack.yaml", "cabal.project", ".cabal"], versionCommand: { cmd: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
36
|
+
{ name: "haskell", files: ["stack.yaml", "cabal.project"], extensions: [".cabal"], versionCommand: { cmd: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
38
37
|
{ name: "julia", files: ["Project.toml", "Manifest.toml"], versionCommand: { cmd: "julia", args: ["--version"], pattern: /julia\s+(\d+\.\d+\.\d+)/ } },
|
|
39
38
|
{ name: "lua", files: ["stylua.toml", ".luarc.json"], versionCommand: { cmd: "lua", args: ["-v"], pattern: /Lua\s+(\d+\.\d+)/ } },
|
|
40
39
|
{ name: "elixir", files: ["mix.exs"], versionCommand: { cmd: "elixir", args: ["--version"], pattern: /Elixir\s+(\d+\.\d+\.\d+)/ } },
|
|
@@ -42,13 +41,13 @@ const RUNTIMES: readonly RuntimeDef[] = [
|
|
|
42
41
|
{ name: "gleam", files: ["gleam.toml"], versionCommand: { cmd: "gleam", args: ["--version"], pattern: /gleam\s+(\d+\.\d+\.\d+)/ } },
|
|
43
42
|
{ name: "crystal", files: ["shard.yml"], versionCommand: { cmd: "crystal", args: ["--version"], pattern: /Crystal\s+(\d+\.\d+\.\d+)/ } },
|
|
44
43
|
{ name: "dart", files: ["pubspec.yaml"], versionCommand: { cmd: "dart", args: ["--version"], pattern: /Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/ } },
|
|
45
|
-
{ name: "nim", files: ["nim.cfg", ".nimble"] },
|
|
44
|
+
{ name: "nim", files: ["nim.cfg"], extensions: [".nimble"] },
|
|
46
45
|
{ name: "zig", files: ["build.zig"], versionCommand: { cmd: "zig", args: ["version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
47
|
-
{ name: "ocaml", files: ["
|
|
46
|
+
{ name: "ocaml", files: ["dune", "dune-project"], extensions: [".opam"] },
|
|
48
47
|
{ name: "clojure", files: ["project.clj", "deps.edn"] },
|
|
49
|
-
{ name: "scala", files: ["build.sbt", ".
|
|
48
|
+
{ name: "scala", files: ["build.sbt", ".metals"], extensions: [".scala"] },
|
|
50
49
|
{ name: "perl", files: ["Makefile.PL", "cpanfile"] },
|
|
51
|
-
{ name: "r", files: ["
|
|
50
|
+
{ name: "r", files: ["DESCRIPTION"], extensions: [".Rproj"] },
|
|
52
51
|
{ name: "elm", files: ["elm.json"] },
|
|
53
52
|
{ name: "haxe", files: ["haxelib.json", ".haxerc"] },
|
|
54
53
|
{ name: "vagrant", files: ["Vagrantfile"] },
|
|
@@ -65,7 +64,6 @@ const RUNTIMES: readonly RuntimeDef[] = [
|
|
|
65
64
|
{ name: "v", files: ["v.mod", "vpkg.json"], extensions: [".v"] },
|
|
66
65
|
{ name: "xmake", files: ["xmake.lua"] },
|
|
67
66
|
{ name: "gradle", files: ["build.gradle", "build.gradle.kts"], folders: ["gradle"] },
|
|
68
|
-
{ name: "maven", files: ["pom.xml"] },
|
|
69
67
|
{ name: "cmake", files: ["CMakeLists.txt", "CMakeCache.txt"] },
|
|
70
68
|
{ name: "meson", files: ["meson.build"], env: "MESON_DEVENV" },
|
|
71
69
|
{ name: "nix", files: ["flake.nix", "shell.nix"], env: "IN_NIX_SHELL" },
|
|
@@ -76,7 +74,7 @@ const RUNTIMES: readonly RuntimeDef[] = [
|
|
|
76
74
|
{ name: "pulumi", files: ["Pulumi.yaml", "Pulumi.yml"] },
|
|
77
75
|
{ name: "typst", files: ["template.typ"], extensions: [".typ"] },
|
|
78
76
|
{ name: "buf", files: ["buf.yaml", "buf.gen.yaml", "buf.work.yaml"] },
|
|
79
|
-
{ name: "dotnet", files: [".
|
|
77
|
+
{ name: "dotnet", files: ["global.json", "Directory.Build.props"], extensions: [".csproj", ".fsproj"] },
|
|
80
78
|
{ name: "cobol", files: [], extensions: [".cbl", ".cob"] },
|
|
81
79
|
];
|
|
82
80
|
|
|
@@ -124,16 +122,17 @@ function matchesDef(cwd: string, def: RuntimeDef): boolean {
|
|
|
124
122
|
async function fetchVersion(def: RuntimeDef, cwd: string): Promise<string | undefined> {
|
|
125
123
|
if (!def.versionCommand) return undefined;
|
|
126
124
|
try {
|
|
127
|
-
const { stdout } = await execFileAsync(def.versionCommand.cmd, def.versionCommand.args ?? [], {
|
|
125
|
+
const { stdout, stderr } = await execFileAsync(def.versionCommand.cmd, def.versionCommand.args ?? [], {
|
|
128
126
|
cwd,
|
|
129
127
|
timeout: VERSION_TIMEOUT_MS,
|
|
130
128
|
maxBuffer: 64 * 1024,
|
|
131
129
|
});
|
|
130
|
+
const output = `${stdout}\n${stderr}`;
|
|
132
131
|
if (def.versionCommand.pattern) {
|
|
133
|
-
const match =
|
|
132
|
+
const match = output.match(def.versionCommand.pattern);
|
|
134
133
|
return match?.[1];
|
|
135
134
|
}
|
|
136
|
-
return
|
|
135
|
+
return output.trim() || undefined;
|
|
137
136
|
} catch {
|
|
138
137
|
return undefined;
|
|
139
138
|
}
|
|
@@ -1,43 +1,23 @@
|
|
|
1
|
-
export class SessionLifecycle {
|
|
2
|
-
private current = 0;
|
|
3
|
-
private
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
this.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return this.current;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
defer(fn: () => void): void {
|
|
28
|
-
if (this.shutDown) return;
|
|
29
|
-
this.deferred.push(fn);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
private flushDeferred(): void {
|
|
33
|
-
const pending = this.deferred;
|
|
34
|
-
this.deferred = [];
|
|
35
|
-
for (const fn of pending) {
|
|
36
|
-
try {
|
|
37
|
-
fn();
|
|
38
|
-
} catch {
|
|
39
|
-
// ponytail: silent fallback — deferred tasks are best-effort
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
1
|
+
export class SessionLifecycle {
|
|
2
|
+
private current = 0;
|
|
3
|
+
private shutDown = false;
|
|
4
|
+
|
|
5
|
+
start(): void {
|
|
6
|
+
this.current++;
|
|
7
|
+
this.shutDown = false;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
shutdown(): void {
|
|
11
|
+
this.shutDown = true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
isCurrent(generation?: number): boolean {
|
|
15
|
+
if (this.shutDown) return false;
|
|
16
|
+
if (generation === undefined) return true;
|
|
17
|
+
return generation === this.current;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
currentGeneration(): number {
|
|
21
|
+
return this.current;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -4,10 +4,11 @@ import {
|
|
|
4
4
|
Key,
|
|
5
5
|
matchesKey,
|
|
6
6
|
SelectList,
|
|
7
|
-
type Component,
|
|
8
7
|
type SelectItem,
|
|
9
8
|
type TUI,
|
|
10
9
|
Text,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
11
12
|
} from "@earendil-works/pi-tui";
|
|
12
13
|
import type { CursorStyle, IconMode, OpenTuiConfig, SettingsLanguage, ThinkingPeekLines } from "./config.ts";
|
|
13
14
|
|
|
@@ -28,8 +29,11 @@ const COPY = {
|
|
|
28
29
|
hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Esc/q: close",
|
|
29
30
|
labels: {
|
|
30
31
|
enabled: "Enabled",
|
|
32
|
+
header: "Override header",
|
|
31
33
|
inlineFooter: "Footer in editor border",
|
|
34
|
+
copyButtons: "Copy buttons",
|
|
32
35
|
thinkingPeek: "Thinking peek",
|
|
36
|
+
toolView: "Tool view",
|
|
33
37
|
language: "Language",
|
|
34
38
|
cursorStyle: "Cursor style",
|
|
35
39
|
iconMode: "Icon mode",
|
|
@@ -56,6 +60,7 @@ const COPY = {
|
|
|
56
60
|
on: "On",
|
|
57
61
|
off: "Off",
|
|
58
62
|
thinkingPeek: { off: "Off", one: "1 line", two: "2 lines" },
|
|
63
|
+
toolView: { native: "Native", compact: "Compact" },
|
|
59
64
|
languages: { en: "English", zh: "简体中文" },
|
|
60
65
|
cursorStyles: { block: "Block", bar: "Bar", underline: "Underline" },
|
|
61
66
|
icons: { auto: "Auto", nerd: "Nerd", unicode: "Unicode", ascii: "ASCII" },
|
|
@@ -67,8 +72,11 @@ const COPY = {
|
|
|
67
72
|
hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · Esc/q:关闭",
|
|
68
73
|
labels: {
|
|
69
74
|
enabled: "启用",
|
|
75
|
+
header: "覆盖顶部信息栏",
|
|
70
76
|
inlineFooter: "底栏嵌入输入框",
|
|
77
|
+
copyButtons: "复制按钮",
|
|
71
78
|
thinkingPeek: "思考预览",
|
|
79
|
+
toolView: "工具视图",
|
|
72
80
|
language: "语言",
|
|
73
81
|
cursorStyle: "光标样式",
|
|
74
82
|
iconMode: "图标模式",
|
|
@@ -95,6 +103,7 @@ const COPY = {
|
|
|
95
103
|
on: "开启",
|
|
96
104
|
off: "关闭",
|
|
97
105
|
thinkingPeek: { off: "关闭", one: "单行", two: "双行" },
|
|
106
|
+
toolView: { native: "原生", compact: "紧凑" },
|
|
98
107
|
languages: { en: "English", zh: "简体中文" },
|
|
99
108
|
cursorStyles: { block: "块", bar: "竖线", underline: "下划线" },
|
|
100
109
|
icons: { auto: "自动", nerd: "Nerd", unicode: "Unicode", ascii: "ASCII" },
|
|
@@ -160,9 +169,12 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
|
|
|
160
169
|
const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
|
|
161
170
|
return [
|
|
162
171
|
{ id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
|
|
172
|
+
{ id: "header", label: copy.labels.header, currentValue: flag(config.header) },
|
|
163
173
|
{ id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
|
|
164
174
|
{ id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
|
|
165
175
|
{ id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
|
|
176
|
+
{ id: "copyButtons", label: copy.labels.copyButtons, currentValue: flag(config.copyButtons) },
|
|
177
|
+
{ id: "toolView", label: copy.labels.toolView, currentValue: copy.values.toolView[config.toolView.mode] },
|
|
166
178
|
];
|
|
167
179
|
}
|
|
168
180
|
|
|
@@ -225,9 +237,14 @@ function handleSettingChange(
|
|
|
225
237
|
): OpenTuiConfig {
|
|
226
238
|
if (tab === "features") {
|
|
227
239
|
if (itemId === "enabled") return toggleEnabled(config);
|
|
240
|
+
if (itemId === "header") return { ...config, header: !config.header };
|
|
228
241
|
if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
|
|
242
|
+
if (itemId === "copyButtons") return { ...config, copyButtons: !config.copyButtons };
|
|
229
243
|
if (itemId === "settingsLanguage") return toggleLanguage(config);
|
|
230
244
|
if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
|
|
245
|
+
if (itemId === "toolView") {
|
|
246
|
+
return { ...config, toolView: { mode: config.toolView.mode === "native" ? "compact" : "native" } };
|
|
247
|
+
}
|
|
231
248
|
}
|
|
232
249
|
if (tab === "icons") {
|
|
233
250
|
if (itemId === "mode") return cycleIconMode(config);
|
|
@@ -251,34 +268,30 @@ interface SettingsUiHandle {
|
|
|
251
268
|
class SettingsUi implements SettingsUiHandle {
|
|
252
269
|
private tab: Tab = "features";
|
|
253
270
|
private config: OpenTuiConfig;
|
|
254
|
-
private selectList
|
|
271
|
+
private selectList!: SelectList;
|
|
255
272
|
private readonly selectedItemByTab: Partial<Record<Tab, string>> = {};
|
|
256
273
|
private readonly container: Box;
|
|
274
|
+
private readonly tui: TUI;
|
|
257
275
|
private readonly theme: Theme;
|
|
258
276
|
private readonly onChange: (config: OpenTuiConfig) => void;
|
|
259
277
|
private readonly onClose: () => void;
|
|
260
|
-
private
|
|
278
|
+
private layoutWidth: number | undefined;
|
|
279
|
+
private layoutRows: number | undefined;
|
|
261
280
|
private cachedLines: string[] | undefined;
|
|
262
|
-
private compact = false;
|
|
263
281
|
|
|
264
282
|
constructor(
|
|
283
|
+
tui: TUI,
|
|
265
284
|
theme: Theme,
|
|
266
285
|
config: OpenTuiConfig,
|
|
267
286
|
onChange: (config: OpenTuiConfig) => void,
|
|
268
287
|
onClose: () => void,
|
|
269
288
|
) {
|
|
289
|
+
this.tui = tui;
|
|
270
290
|
this.theme = theme;
|
|
271
291
|
this.config = config;
|
|
272
292
|
this.onChange = onChange;
|
|
273
293
|
this.onClose = onClose;
|
|
274
294
|
this.container = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
|
|
275
|
-
this.selectList = new SelectList([], 12, {
|
|
276
|
-
selectedPrefix: (t) => theme.fg("accent", t),
|
|
277
|
-
selectedText: (t) => theme.fg("accent", t),
|
|
278
|
-
description: (t) => theme.fg("muted", t),
|
|
279
|
-
scrollInfo: (t) => theme.fg("dim", t),
|
|
280
|
-
noMatch: (t) => theme.fg("warning", t),
|
|
281
|
-
});
|
|
282
295
|
this.rebuild();
|
|
283
296
|
}
|
|
284
297
|
|
|
@@ -295,7 +308,13 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
295
308
|
this.rebuild();
|
|
296
309
|
}
|
|
297
310
|
|
|
298
|
-
private rebuild(
|
|
311
|
+
private rebuild(
|
|
312
|
+
preferredItemId = this.selectedItemByTab[this.tab],
|
|
313
|
+
width = this.layoutWidth ?? Math.min(80, this.tui.terminal.columns),
|
|
314
|
+
): void {
|
|
315
|
+
this.layoutWidth = width;
|
|
316
|
+
this.layoutRows = this.tui.terminal.rows;
|
|
317
|
+
const compact = width <= 60;
|
|
299
318
|
const copy = COPY[this.config.settingsLanguage];
|
|
300
319
|
this.container.clear();
|
|
301
320
|
this.container.addChild(new Text(this.theme.bold(this.theme.fg("accent", copy.title)), 1, 0));
|
|
@@ -308,12 +327,18 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
308
327
|
this.container.addChild(new Text(tabBar, 1, 0));
|
|
309
328
|
this.container.addChild(new Text(this.theme.fg("dim", copy.hint), 1, 0));
|
|
310
329
|
|
|
311
|
-
const items = buildItems(this.tab, this.config).map((item) => ({
|
|
330
|
+
const items = buildItems(this.tab, this.config).map<SelectItem>((item) => ({
|
|
312
331
|
value: item.id,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
332
|
+
// Reserve Box padding (2), SelectList prefix/safety (4), and ": " (2).
|
|
333
|
+
label: compact
|
|
334
|
+
? `${truncateToWidth(item.label, Math.max(0, width - 8 - visibleWidth(item.currentValue)))}: ${item.currentValue}`
|
|
335
|
+
: item.label,
|
|
336
|
+
description: compact ? undefined : item.currentValue,
|
|
337
|
+
}));
|
|
338
|
+
const availableRows = this.layoutRows - this.container.render(width).length;
|
|
339
|
+
// A scrolling SelectList adds one status row below the items.
|
|
340
|
+
const visibleItems = items.length <= availableRows ? items.length : Math.max(1, availableRows - 1);
|
|
341
|
+
this.selectList = new SelectList(items, visibleItems, {
|
|
317
342
|
selectedPrefix: (t) => this.theme.fg("accent", t),
|
|
318
343
|
selectedText: (t) => this.theme.fg("accent", t),
|
|
319
344
|
description: (t) => this.theme.fg("muted", t),
|
|
@@ -336,7 +361,6 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
336
361
|
};
|
|
337
362
|
this.container.addChild(this.selectList);
|
|
338
363
|
|
|
339
|
-
this.cachedWidth = undefined;
|
|
340
364
|
this.cachedLines = undefined;
|
|
341
365
|
}
|
|
342
366
|
|
|
@@ -365,19 +389,15 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
365
389
|
}
|
|
366
390
|
|
|
367
391
|
render(width: number): string[] {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
this.compact = compact;
|
|
371
|
-
this.rebuild();
|
|
392
|
+
if (width !== this.layoutWidth || this.tui.terminal.rows !== this.layoutRows) {
|
|
393
|
+
this.rebuild(undefined, width);
|
|
372
394
|
}
|
|
373
|
-
if (this.cachedLines
|
|
374
|
-
this.cachedWidth = width;
|
|
395
|
+
if (this.cachedLines) return this.cachedLines;
|
|
375
396
|
this.cachedLines = this.container.render(width);
|
|
376
397
|
return this.cachedLines;
|
|
377
398
|
}
|
|
378
399
|
|
|
379
400
|
invalidate(): void {
|
|
380
|
-
this.cachedWidth = undefined;
|
|
381
401
|
this.cachedLines = undefined;
|
|
382
402
|
this.container.invalidate();
|
|
383
403
|
}
|
|
@@ -395,26 +415,27 @@ export function registerSettingsCommand(
|
|
|
395
415
|
description: "Open the open-tui settings UI",
|
|
396
416
|
handler: async (_args, ctx: ExtensionContext) => {
|
|
397
417
|
if (!ctx.hasUI) return;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
+
await ctx.ui.custom<void>((tui: TUI, theme, _kb, done) => {
|
|
419
|
+
const ui = new SettingsUi(
|
|
420
|
+
tui,
|
|
421
|
+
theme,
|
|
422
|
+
hooks.getConfig(),
|
|
423
|
+
(config) => hooks.onConfigChanged(config),
|
|
424
|
+
() => done(undefined),
|
|
425
|
+
);
|
|
426
|
+
return {
|
|
427
|
+
render: (w: number) => ui.render(w),
|
|
428
|
+
invalidate: () => ui.invalidate(),
|
|
429
|
+
handleInput: (data: string) => {
|
|
430
|
+
ui.handleInput(data);
|
|
431
|
+
tui.requestRender();
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}, { overlay: true });
|
|
435
|
+
// Overlay is closed and focus is back on the editor. Deferred UI changes
|
|
436
|
+
// (e.g. toggling the extension) run here, so pi core's focus restore
|
|
437
|
+
// cannot strand the overlay without keyboard input.
|
|
438
|
+
hooks.onOverlayClosed?.();
|
|
418
439
|
},
|
|
419
440
|
});
|
|
420
441
|
}
|
|
@@ -8,7 +8,6 @@ import { finiteOrZero, formatProviderLabel } from "./utils.ts";
|
|
|
8
8
|
export interface FooterState {
|
|
9
9
|
git: GitStatus;
|
|
10
10
|
runtime: RuntimeInfo | null;
|
|
11
|
-
sessionStartEpoch: number;
|
|
12
11
|
workingSince: number | undefined;
|
|
13
12
|
lastDoneIn: number | undefined;
|
|
14
13
|
}
|
|
@@ -22,23 +21,22 @@ export interface UsageTotals {
|
|
|
22
21
|
latestCacheHitRate: number | undefined;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
let usageCache: {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return `${entries.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
|
|
31
|
-
}
|
|
24
|
+
let usageCache: {
|
|
25
|
+
sessionManager: ExtensionContext["sessionManager"];
|
|
26
|
+
leafId: string | null;
|
|
27
|
+
totals: UsageTotals;
|
|
28
|
+
} | undefined;
|
|
32
29
|
|
|
33
30
|
export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
34
|
-
const
|
|
35
|
-
|
|
31
|
+
const { sessionManager } = ctx;
|
|
32
|
+
const leafId = sessionManager.getLeafId();
|
|
33
|
+
if (usageCache?.sessionManager === sessionManager && usageCache.leafId === leafId) return usageCache.totals;
|
|
36
34
|
|
|
37
35
|
const totals: UsageTotals = {
|
|
38
36
|
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0,
|
|
39
37
|
latestCacheHitRate: undefined,
|
|
40
38
|
};
|
|
41
|
-
for (const entry of
|
|
39
|
+
for (const entry of sessionManager.getEntries()) {
|
|
42
40
|
let u: Usage | undefined;
|
|
43
41
|
let updateCacheHitRate = false;
|
|
44
42
|
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
@@ -46,7 +44,7 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
|
46
44
|
updateCacheHitRate = true;
|
|
47
45
|
} else if (entry.type === "message" && entry.message.role === "toolResult") {
|
|
48
46
|
u = entry.message.usage;
|
|
49
|
-
} else if (entry.type === "branch_summary" || entry.type === "compaction") {
|
|
47
|
+
} else if (entry.type === "usage" || entry.type === "branch_summary" || entry.type === "compaction") {
|
|
50
48
|
u = entry.usage;
|
|
51
49
|
}
|
|
52
50
|
if (!u) continue;
|
|
@@ -67,7 +65,7 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
|
67
65
|
: undefined;
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
|
-
usageCache = {
|
|
68
|
+
usageCache = { sessionManager, leafId, totals };
|
|
71
69
|
return totals;
|
|
72
70
|
}
|
|
73
71
|
|
|
@@ -79,7 +77,6 @@ export function createInitialState(): FooterState {
|
|
|
79
77
|
return {
|
|
80
78
|
git: emptyGitStatus(),
|
|
81
79
|
runtime: null,
|
|
82
|
-
sessionStartEpoch: Date.now(),
|
|
83
80
|
workingSince: undefined,
|
|
84
81
|
lastDoneIn: undefined,
|
|
85
82
|
};
|