mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
@@ -8,6 +8,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `/tree` help rendering to show compact wrapped controls instead of truncating them on narrow terminals ([#5055](https://github.com/earendil-works/pi/issues/5055)).
|
||||
- Fixed SIGTERM/SIGHUP interactive shutdown to keep signal handlers installed until terminal cleanup completes, preventing `signal-exit` from re-sending the signal and leaving the terminal in raw/Kitty keyboard mode ([#5724](https://github.com/earendil-works/pi/issues/5724)).
|
||||
- Fixed extensions documentation to clarify that `pi.getActiveTools()` returns active tool names while `pi.getAllTools()` returns tool metadata ([#5729](https://github.com/earendil-works/pi/issues/5729)).
|
||||
- Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)).
|
||||
|
||||
@@ -4,15 +4,17 @@ import {
|
||||
type Focusable,
|
||||
getKeybindings,
|
||||
Input,
|
||||
type Keybinding,
|
||||
Spacer,
|
||||
Text,
|
||||
TruncatedText,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import type { SessionTreeNode } from "../../../core/session-manager.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint, keyText } from "./keybinding-hints.ts";
|
||||
import { formatKeyText, keyHint } from "./keybinding-hints.ts";
|
||||
|
||||
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
|
||||
interface GutterInfo {
|
||||
@@ -1075,6 +1077,98 @@ class SearchLine implements Component {
|
||||
handleInput(_keyData: string): void {}
|
||||
}
|
||||
|
||||
/** Component that renders tree help as semantic rows with chunk-aware wrapping */
|
||||
class TreeHelp implements Component {
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const items = TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => {
|
||||
const text = formatHelpKeys(keys);
|
||||
if (!text) return label;
|
||||
return labelFirst ? `${label} ${text}` : `${text} ${label}`;
|
||||
});
|
||||
|
||||
const availableWidth = Math.max(1, width);
|
||||
const indent = " ";
|
||||
const separator = " · ";
|
||||
const lines: string[] = [];
|
||||
let currentLine = "";
|
||||
|
||||
for (const item of items) {
|
||||
const candidate = currentLine
|
||||
? `${currentLine}${separator}${item}`
|
||||
: visibleWidth(`${indent}${item}`) <= availableWidth
|
||||
? `${indent}${item}`
|
||||
: item;
|
||||
if (!currentLine || visibleWidth(candidate) <= availableWidth) {
|
||||
currentLine = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth));
|
||||
currentLine = visibleWidth(`${indent}${item}`) <= availableWidth ? `${indent}${item}` : item;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth));
|
||||
}
|
||||
|
||||
return lines.map((line) => theme.fg("muted", line));
|
||||
}
|
||||
}
|
||||
|
||||
const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: boolean }> = [
|
||||
{ keys: ["tui.select.up", "tui.select.down"], label: "move" },
|
||||
{ keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
|
||||
{ keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
|
||||
{ keys: ["app.tree.editLabel"], label: "label" },
|
||||
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
|
||||
{
|
||||
keys: [
|
||||
"app.tree.filter.default",
|
||||
"app.tree.filter.noTools",
|
||||
"app.tree.filter.userOnly",
|
||||
"app.tree.filter.labeledOnly",
|
||||
"app.tree.filter.all",
|
||||
],
|
||||
label: "filters",
|
||||
labelFirst: true,
|
||||
},
|
||||
{ keys: ["app.tree.filter.cycleForward", "app.tree.filter.cycleBackward"], label: "cycle", labelFirst: true },
|
||||
];
|
||||
|
||||
function formatHelpKeys(keybindings: Keybinding[]): string {
|
||||
const keys: string[] = [];
|
||||
for (const keybinding of keybindings) {
|
||||
const key = getKeybindings().getKeys(keybinding)[0];
|
||||
if (key !== undefined) keys.push(key);
|
||||
}
|
||||
if (keys.length === 0) return "";
|
||||
|
||||
return formatKeyText(compactRawKeys(keys))
|
||||
.replace(/\bpageUp\b/g, "pgup")
|
||||
.replace(/\bpageDown\b/g, "pgdn")
|
||||
.replace(/\bup\b/g, "↑")
|
||||
.replace(/\bdown\b/g, "↓")
|
||||
.replace(/\bleft\b/g, "←")
|
||||
.replace(/\bright\b/g, "→");
|
||||
}
|
||||
|
||||
function compactRawKeys(keys: string[]): string {
|
||||
if (keys.length === 1) return keys[0]!;
|
||||
|
||||
const parts = keys.map((key) => {
|
||||
const separatorIndex = key.lastIndexOf("+");
|
||||
return separatorIndex === -1
|
||||
? { prefix: "", suffix: key }
|
||||
: { prefix: key.slice(0, separatorIndex + 1), suffix: key.slice(separatorIndex + 1) };
|
||||
});
|
||||
const prefix = parts[0]!.prefix;
|
||||
return prefix && parts.every((part) => part.prefix === prefix)
|
||||
? `${prefix}${parts.map((part) => part.suffix).join("/")}`
|
||||
: keys.join("/");
|
||||
}
|
||||
|
||||
/** Label input component shown when editing a label */
|
||||
class LabelInput implements Component, Focusable {
|
||||
private input: Input;
|
||||
@@ -1181,25 +1275,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
|
||||
const filterKeys = [
|
||||
keyText("app.tree.filter.default"),
|
||||
keyText("app.tree.filter.noTools"),
|
||||
keyText("app.tree.filter.userOnly"),
|
||||
keyText("app.tree.filter.labeledOnly"),
|
||||
keyText("app.tree.filter.all"),
|
||||
].join("/");
|
||||
const cycleKeys = `${keyText("app.tree.filter.cycleForward")}/${keyText("app.tree.filter.cycleBackward")}`;
|
||||
const branchKeys = `${keyText("app.tree.foldOrUp")}/${keyText("app.tree.unfoldOrDown")}`;
|
||||
this.addChild(
|
||||
new TruncatedText(
|
||||
theme.fg(
|
||||
"muted",
|
||||
` ↑/↓: move. ←/→: page. ${branchKeys}: fold/branch. ${keyText("app.tree.editLabel")}: label. ${filterKeys}: filters (${cycleKeys} cycle). ${keyText("app.tree.toggleLabelTimestamp")}: label time`,
|
||||
),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.addChild(new TreeHelp());
|
||||
this.addChild(new SearchLine(this.treeList));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { setKeybindings } from "@earendil-works/pi-tui";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import { setKeybindings, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { KeybindingsManager } from "../src/core/keybindings.ts";
|
||||
import type {
|
||||
@@ -248,6 +249,29 @@ describe("TreeSelectorComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("help", () => {
|
||||
test("renders semantic help rows without truncating narrow terminal controls", () => {
|
||||
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];
|
||||
const tree = buildTree(entries);
|
||||
const selector = new TreeSelectorComponent(
|
||||
tree,
|
||||
"asst-1",
|
||||
24,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
const plainLines = selector.render(30).map(stripVTControlCharacters);
|
||||
const plain = plainLines.join("\n");
|
||||
expect(plain).toContain("branch");
|
||||
expect(plain).toContain("filters");
|
||||
expect(plain).toContain("cycle");
|
||||
expect(plain).toContain("label time");
|
||||
expect(plain).not.toContain("...");
|
||||
expect(plainLines.every((line) => visibleWidth(line) <= 30)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("label timestamps", () => {
|
||||
test("toggles label timestamps for labeled nodes", () => {
|
||||
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];
|
||||
|
||||
Reference in New Issue
Block a user