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.
@@ -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
- try {
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
- cleanupHeader = installHeader(pi, ctx);
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() || isAgentIdle(ctx)) return;
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() || isAgentIdle(ctx)) return;
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: [".opam", "dune", "dune-project"] },
46
+ { name: "ocaml", files: ["dune", "dune-project"], extensions: [".opam"] },
48
47
  { name: "clojure", files: ["project.clj", "deps.edn"] },
49
- { name: "scala", files: ["build.sbt", ".scala", ".metals"] },
48
+ { name: "scala", files: ["build.sbt", ".metals"], extensions: [".scala"] },
50
49
  { name: "perl", files: ["Makefile.PL", "cpanfile"] },
51
- { name: "r", files: [".Rproj", "DESCRIPTION"] },
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: [".csproj", ".fsproj", "global.json", "Directory.Build.props"] },
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 = stdout.match(def.versionCommand.pattern);
132
+ const match = output.match(def.versionCommand.pattern);
134
133
  return match?.[1];
135
134
  }
136
- return stdout.trim() || undefined;
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 deferred: Array<() => void> = [];
4
- private shutDown = false;
5
-
6
- start(): void {
7
- this.current++;
8
- this.shutDown = false;
9
- this.flushDeferred();
10
- }
11
-
12
- shutdown(): void {
13
- this.shutDown = true;
14
- this.deferred = [];
15
- }
16
-
17
- isCurrent(generation?: number): boolean {
18
- if (this.shutDown) return false;
19
- if (generation === undefined) return true;
20
- return generation === this.current;
21
- }
22
-
23
- currentGeneration(): number {
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: 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 cachedWidth: number | undefined;
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(preferredItemId = this.selectedItemByTab[this.tab]): void {
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
- label: this.compact ? `${item.label}: ${item.currentValue}` : item.label,
314
- description: this.compact ? undefined : item.currentValue,
315
- } as SelectItem));
316
- this.selectList = new SelectList(items, Math.min(items.length, 12), {
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
- const compact = width <= 60;
369
- if (compact !== this.compact) {
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 && this.cachedWidth === width) return 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
- await ctx.ui.custom<void>((tui: TUI, theme, _kb, done) => {
399
- const ui = new SettingsUi(
400
- theme,
401
- hooks.getConfig(),
402
- (config) => hooks.onConfigChanged(config),
403
- () => done(undefined),
404
- );
405
- return {
406
- render: (w: number) => ui.render(w),
407
- invalidate: () => ui.invalidate(),
408
- handleInput: (data: string) => {
409
- ui.handleInput(data);
410
- tui.requestRender();
411
- },
412
- };
413
- }, { overlay: true });
414
- // Overlay is closed and focus is back on the editor. Deferred UI changes
415
- // (e.g. toggling the extension) run here, so pi core's focus restore
416
- // cannot strand the overlay without keyboard input.
417
- hooks.onOverlayClosed?.();
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: { key: string; totals: UsageTotals } | undefined;
26
-
27
- function entriesKey(ctx: ExtensionContext): string {
28
- const entries = ctx.sessionManager.getEntries();
29
- const last = entries.at(-1);
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 key = entriesKey(ctx);
35
- if (usageCache && usageCache.key === key) return usageCache.totals;
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 ctx.sessionManager.getEntries()) {
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 = { key, totals };
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
  };