@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### 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 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 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)).
|
- 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,
|
type Focusable,
|
||||||
getKeybindings,
|
getKeybindings,
|
||||||
Input,
|
Input,
|
||||||
|
type Keybinding,
|
||||||
Spacer,
|
Spacer,
|
||||||
Text,
|
Text,
|
||||||
TruncatedText,
|
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
|
visibleWidth,
|
||||||
|
wrapTextWithAnsi,
|
||||||
} from "@earendil-works/pi-tui";
|
} from "@earendil-works/pi-tui";
|
||||||
import type { SessionTreeNode } from "../../../core/session-manager.ts";
|
import type { SessionTreeNode } from "../../../core/session-manager.ts";
|
||||||
import { theme } from "../theme/theme.ts";
|
import { theme } from "../theme/theme.ts";
|
||||||
import { DynamicBorder } from "./dynamic-border.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 │ */
|
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
|
||||||
interface GutterInfo {
|
interface GutterInfo {
|
||||||
@@ -1075,6 +1077,98 @@ class SearchLine implements Component {
|
|||||||
handleInput(_keyData: string): void {}
|
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 */
|
/** Label input component shown when editing a label */
|
||||||
class LabelInput implements Component, Focusable {
|
class LabelInput implements Component, Focusable {
|
||||||
private input: Input;
|
private input: Input;
|
||||||
@@ -1181,25 +1275,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
|
|||||||
this.addChild(new Spacer(1));
|
this.addChild(new Spacer(1));
|
||||||
this.addChild(new DynamicBorder());
|
this.addChild(new DynamicBorder());
|
||||||
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
|
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
|
||||||
const filterKeys = [
|
this.addChild(new TreeHelp());
|
||||||
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 SearchLine(this.treeList));
|
this.addChild(new SearchLine(this.treeList));
|
||||||
this.addChild(new DynamicBorder());
|
this.addChild(new DynamicBorder());
|
||||||
this.addChild(new Spacer(1));
|
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 { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { KeybindingsManager } from "../src/core/keybindings.ts";
|
import { KeybindingsManager } from "../src/core/keybindings.ts";
|
||||||
import type {
|
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", () => {
|
describe("label timestamps", () => {
|
||||||
test("toggles label timestamps for labeled nodes", () => {
|
test("toggles label timestamps for labeled nodes", () => {
|
||||||
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];
|
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];
|
||||||
|
|||||||
Reference in New Issue
Block a user