Configuration, personnalisation, paramètres de plateforme et références API pour Pi.

TUI Composants

pi peut créer TUI composants. Demandez-lui d'en créer un pour votre cas d'utilisation.

Extensions et les outils personnalisés peuvent restituer des composants TUI personnalisés pour les interfaces utilisateur interactives. Cette page couvre le système de composants et les blocs de construction disponibles.

Source: @earendil-works/pi-tui

Interface des composants

Tous les composants implémentent:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}
Méthode Description
render(width) Renvoie un tableau de chaînes (une par ligne). Chaque ligne ne doit pas dépasser width.
handleInput?(data) Recevoir une entrée au clavier lorsque le composant a le focus.
wantsKeyRelease? Si c'est vrai, le composant reçoit les événements de version de clé (protocole Kitty). Par défaut: faux.
invalidate() Effacer l'état de rendu mis en cache. Appelé aux changements de thème.

Le TUI ajoute une réinitialisation complète SGR et une réinitialisation OSC 8 à la fin de chaque ligne rendue. Les styles ne s’étendent pas sur les lignes. Si vous émettez du texte multiligne avec style, réappliquez les styles par ligne ou utilisez wrapTextWithAnsi() afin que les styles soient préservés pour chaque ligne renvoyée à la ligne.

Interface focalisable (prise en charge IME)

Les composants qui affichent un curseur de texte et nécessitent la prise en charge IME (Input Method Editor) doivent implémenter l'interface Focusable:

import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";

class MyInput implements Component, Focusable {
  focused: boolean = false;  // Set by TUI when focus changes
  
  render(width: number): string[] {
    const marker = this.focused ? CURSOR_MARKER : "";
    // Emit marker right before the fake cursor
    return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
  }
}

Lorsqu'un composant Focusable a le focus, TUI:

  1. Définit focused = true sur le composant
  2. Analyse la sortie rendue pour CURSOR_MARKER (une séquence d'échappement APC de largeur nulle)
  3. Positionne le curseur du terminal matériel à cet emplacement
  4. Affiche le curseur matériel uniquement lorsque showHardwareCursor est activé

Le curseur reste masqué par défaut. Cela conserve le faux rendu du curseur, tout en positionnant le curseur matériel pour les terminaux qui suivent les fenêtres candidates IME avec des curseurs cachés. Certains terminaux nécessitent un curseur matériel visible pour le positionnement IME; activez-le avec showHardwareCursor, setShowHardwareCursor(true) ou PI_HARDWARE_CURSOR=1. Les composants intégrés Editor et Input implémentent déjà cette interface.

Composants de conteneur avec entrées intégrées

Lorsqu'un composant conteneur (boîte de dialogue, sélecteur, etc.) contient un enfant Input ou Editor, le conteneur doit implémenter Focusable et propager l'état de focus à l'enfant. Sinon, le curseur matériel ne sera pas positionné correctement pour la saisie IME.

import { Container, type Focusable, Input } from "@earendil-works/pi-tui";

class SearchDialog extends Container implements Focusable {
  private searchInput: Input;

  // Focusable implementation - propagate to child input for IME cursor positioning
  private _focused = false;
  get focused(): boolean {
    return this._focused;
  }
  set focused(value: boolean) {
    this._focused = value;
    this.searchInput.focused = value;
  }

  constructor() {
    super();
    this.searchInput = new Input();
    this.addChild(this.searchInput);
  }
}

Sans cette propagation, taper avec un IME (chinois, japonais, coréen, etc.) affichera la fenêtre candidat dans la mauvaise position à l'écran.

Utilisation de composants

Dans les extensions via ctx.ui.custom():

pi.on("session_start", async (_event, ctx) => {
  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
    new MyComponent({
      theme,
      keybindings,
      onChange: () => tui.requestRender(),
      onSelect: (value) => done(value),
      onCancel: () => done(null),
    })
  );
});

Dans les outils personnalisés via ctx.ui.custom():

async execute(toolCallId, params, signal, onUpdate, ctx) {
  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
    new MyComponent({
      theme,
      keybindings,
      onChange: () => tui.requestRender(),
      onSelect: (value) => done(value),
      onCancel: () => done(null),
    })
  );
  // Use result...
}

Superpositions

Les superpositions affichent les composants au-dessus du contenu existant sans effacer l'écran. Passez { overlay: true } à ctx.ui.custom():

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
  { overlay: true }
);

Pour le positionnement et le dimensionnement, utilisez overlayOptions:

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
  {
    overlay: true,
    overlayOptions: {
      // Size: number or percentage string
      width: "50%",          // 50% of terminal width
      minWidth: 40,          // minimum 40 columns
      maxHeight: "80%",      // max 80% of terminal height

      // Position: anchor-based (default: "center")
      anchor: "right-center", // 9 positions: center, top-left, top-center, etc.
      offsetX: -2,            // offset from anchor
      offsetY: 0,

      // Or percentage/absolute positioning
      row: "25%",            // 25% from top
      col: 10,               // column 10

      // Margins
      margin: 2,             // all sides, or { top, right, bottom, left }

      // Responsive: hide on narrow terminals
      visible: (termWidth, termHeight) => termWidth >= 80,
    },
    // Get handle for programmatic focus and visibility control
    onHandle: (handle) => {
      // handle.focus() - focus this overlay and bring it to the visual front
      // handle.unfocus() - release input to normal fallback
      // handle.unfocus({ target }) - release input to a specific component or null
      // handle.setHidden(true/false) - toggle visibility
      // handle.hide() - permanently remove
    },
  }
);

Mise au point de superposition

Une superposition visible et ciblée conserve la propriété des entrées dans l'interface utilisateur temporaire sans superposition. Si une superposition ouvre un autre composant ctx.ui.custom() sans { overlay: true }, cette interface utilisateur de remplacement reçoit une entrée pendant qu'elle est active; lorsqu'elle se ferme, la superposition ciblée peut récupérer l'entrée.

Utilisez handle.unfocus() lorsqu'une superposition visible ne doit plus posséder d'entrée et laissez TUI revenir à une autre superposition de capture visible ou à la cible de focus précédente. Utilisez handle.unfocus({ target }) lorsqu'un composant spécifique doit recevoir une entrée tandis que la superposition reste visible. Passer { target: null } intentionnellement ne laisse aucun composant focalisé jusqu'à ce que le focus soit à nouveau défini.

Cycle de vie de la superposition

Les composants de superposition sont éliminés une fois fermés. Ne réutilisez pas les références - créez de nouvelles instances:

// Wrong - stale reference
let menu: MenuComponent;
await ctx.ui.custom((_, __, ___, done) => {
  menu = new MenuComponent(done);
  return menu;
}, { overlay: true });
setActiveComponent(menu);  // Disposed

// Correct - re-call to re-show
const showMenu = () => ctx.ui.custom((_, __, ___, done) => 
  new MenuComponent(done), { overlay: true });

await showMenu();  // First show
await showMenu();  // "Back" = just call again

Voir overlay-qa-tests.ts pour des exemples complets couvrant les ancres, les marges, l'empilement, la visibilité réactive et l'animation.

Composants intégrés

Importer depuis @earendil-works/pi-tui:

import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";

Texte

Texte multiligne avec retour à la ligne.

const text = new Text(
  "Hello World",    // content
  1,                // paddingX (default: 1)
  1,                // paddingY (default: 1)
  (s) => bgGray(s)  // optional background function
);
text.setText("Updated");

Boîte

Conteneur avec rembourrage et couleur d'arrière-plan.

const box = new Box(
  1,                // paddingX
  1,                // paddingY
  (s) => bgGray(s)  // background function
);
box.addChild(new Text("Content", 0, 0));
box.setBgFn((s) => bgBlue(s));

Récipient

Regroupe les composants enfants verticalement.

const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);

Entretoise

Espace vertical vide.

const spacer = new Spacer(2);  // 2 empty lines

Markdown

Rend le démarque avec la coloration syntaxique.

const md = new Markdown(
  "# Title\n\nSome **bold** text",
  1,        // paddingX
  1,        // paddingY
  theme     // MarkdownTheme (see below)
);
md.setText("Updated markdown");

Image

Rend les images dans les terminaux pris en charge (Kitty, iTerm2, Ghostty, WezTerm, Warp).

const image = new Image(
  base64Data,   // base64-encoded image
  "image/png",  // MIME type
  theme,        // ImageTheme
  { maxWidthCells: 80, maxHeightCells: 24 }
);

Entrée au clavier

Utilisez matchesKey() pour la détection de clé:

import { matchesKey, Key } from "@earendil-works/pi-tui";

handleInput(data: string) {
  if (matchesKey(data, Key.up)) {
    this.selectedIndex--;
  } else if (matchesKey(data, Key.enter)) {
    this.onSelect?.(this.selectedIndex);
  } else if (matchesKey(data, Key.escape)) {
    this.onCancel?.();
  } else if (matchesKey(data, Key.ctrl("c"))) {
    // Ctrl+C
  }
}

Identifiants de clé (utilisez Key.* pour la saisie semi-automatique ou les chaînes littérales):

  • Touches de base: Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end
  • Touches fléchées: Key.up, Key.down, Key.left, Key.right
  • Avec modificateurs: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
  • Le format de chaîne fonctionne également: "enter", "ctrl+c", "shift+tab", "ctrl+shift+p"

Largeur de ligne

Critique: Chaque ligne de render() ne doit pas dépasser le paramètre width.

import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";

render(width: number): string[] {
  // Truncate long lines
  return [truncateToWidth(this.text, width)];
}

Utilitaires:

  • visibleWidth(str) - Obtenir la largeur d'affichage (ignore les codes ANSI)
  • truncateToWidth(str, width, ellipsis?) - Tronquer avec des points de suspension facultatifs
  • wrapTextWithAnsi(str, width) - Retour à la ligne préservant les codes ANSI

Création de composants personnalisés

Exemple: sélecteur interactif

import {
  matchesKey, Key,
  truncateToWidth, visibleWidth
} from "@earendil-works/pi-tui";

class MySelector {
  private items: string[];
  private selected = 0;
  private cachedWidth?: number;
  private cachedLines?: string[];
  
  public onSelect?: (item: string) => void;
  public onCancel?: () => void;

  constructor(items: string[]) {
    this.items = items;
  }

  handleInput(data: string): void {
    if (matchesKey(data, Key.up) && this.selected > 0) {
      this.selected--;
      this.invalidate();
    } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
      this.selected++;
      this.invalidate();
    } else if (matchesKey(data, Key.enter)) {
      this.onSelect?.(this.items[this.selected]);
    } else if (matchesKey(data, Key.escape)) {
      this.onCancel?.();
    }
  }

  render(width: number): string[] {
    if (this.cachedLines && this.cachedWidth === width) {
      return this.cachedLines;
    }

    this.cachedLines = this.items.map((item, i) => {
      const prefix = i === this.selected ? "> " : "  ";
      return truncateToWidth(prefix + item, width);
    });
    this.cachedWidth = width;
    return this.cachedLines;
  }

  invalidate(): void {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
  }
}

Utilisation dans une extension:

pi.registerCommand("pick", {
  description: "Pick an item",
  handler: async (_args, ctx) => {
    const items = ["Option A", "Option B", "Option C"];
    const selected = await ctx.ui.custom<string | null>((tui, _theme, _keybindings, done) => {
      const selector = new MySelector(items);
      selector.onSelect = done;
      selector.onCancel = () => done(null);

      return {
        render: (width) => selector.render(width),
        handleInput: (data) => {
          selector.handleInput(data);
          tui.requestRender();
        },
        invalidate: () => selector.invalidate(),
      };
    });

    if (selected !== null) {
      ctx.ui.notify(`Selected: ${selected}`, "info");
    }
  }
});

Thématisation

Les composants acceptent les objets de thème pour le style.

Dans renderCall/renderResult, utilisez le paramètre theme:

renderResult(result, options, theme, context) {
  // Use theme.fg() for foreground colors
  return new Text(theme.fg("success", "Done!"), 0, 0);
  
  // Use theme.bg() for background colors
  const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}

Couleurs de premier plan (theme.fg(color, text)):

Catégorie Couleurs
Général text, accent, muted, dim
Statut success, error, warning
Frontières border, borderAccent, borderMuted
Messages userMessageText, customMessageText, customMessageLabel
Outils toolTitle, toolOutput
Différences toolDiffAdded, toolDiffRemoved, toolDiffContext
Markdown mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
Syntaxe syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
Pensée thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax
Modes bashMode

Couleurs de fond (theme.bg(color, text)):

selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg

Pour Markdown, utilisez getMarkdownTheme():

import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
import { Markdown } from "@earendil-works/pi-tui";

renderResult(result, options, theme, context) {
  const mdTheme = getMarkdownTheme();
  return new Markdown(result.details.markdown, 0, 0, mdTheme);
}

Pour les composants personnalisés, définissez votre propre interface de thème:

interface MyTheme {
  selected: (s: string) => string;
  normal: (s: string) => string;
}

Journalisation du débogage

Définissez PI_TUI_WRITE_LOG pour capturer le flux ANSI brut écrit sur stdout.

PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts

Performance

Cacher la sortie rendue lorsque cela est possible:

class CachedComponent {
  private cachedWidth?: number;
  private cachedLines?: string[];

  render(width: number): string[] {
    if (this.cachedLines && this.cachedWidth === width) {
      return this.cachedLines;
    }
    // ... compute lines ...
    this.cachedWidth = width;
    this.cachedLines = lines;
    return lines;
  }

  invalidate(): void {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
  }
}

Appelez invalidate() lorsque l'état change, puis utilisez le tui.requestRender() injecté pour déclencher un nouveau rendu.

Invalidation et changements de thème

Lorsque le thème change, le TUI appelle invalidate() sur tous les composants pour vider leurs caches. Les composants doivent implémenter correctement invalidate() pour garantir que les changements de thème prennent effet.

Le problème

Si un composant pré-prépare les couleurs du thème dans des chaînes (via theme.fg(), theme.bg(), etc.) et les met en cache, les chaînes mises en cache contiennent les codes d'échappement ANSI de l'ancien thème. Il ne suffit pas de vider le cache de rendu si le composant stocke le contenu thématique séparément.

Mauvaise approche (les couleurs du thème ne seront pas mises à jour):

class BadComponent extends Container {
  private content: Text;

  constructor(message: string, theme: Theme) {
    super();
    // Pre-baked theme colors stored in Text component
    this.content = new Text(theme.fg("accent", message), 1, 0);
    this.addChild(this.content);
  }
  // No invalidate override - parent's invalidate only clears
  // child render caches, not the pre-baked content
}

La solution

Les composants qui créent du contenu avec des couleurs de thème doivent reconstruire ce contenu lorsque invalidate() est appelé:

class GoodComponent extends Container {
  private message: string;
  private content: Text;

  constructor(message: string) {
    super();
    this.message = message;
    this.content = new Text("", 1, 0);
    this.addChild(this.content);
    this.updateDisplay();
  }

  private updateDisplay(): void {
    // Rebuild content with current theme
    this.content.setText(theme.fg("accent", this.message));
  }

  override invalidate(): void {
    super.invalidate();  // Clear child caches
    this.updateDisplay(); // Rebuild with new theme
  }
}

Modèle: Reconstruire en cas d'invalidation

Pour les composants au contenu complexe:

class ComplexComponent extends Container {
  private data: SomeData;

  constructor(data: SomeData) {
    super();
    this.data = data;
    this.rebuild();
  }

  private rebuild(): void {
    this.clear();  // Remove all children

    // Build UI with current theme
    this.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));
    this.addChild(new Spacer(1));

    for (const item of this.data.items) {
      const color = item.active ? "success" : "muted";
      this.addChild(new Text(theme.fg(color, item.label), 1, 0));
    }
  }

  override invalidate(): void {
    super.invalidate();
    this.rebuild();
  }
}

Quand c'est important

Ce modèle est nécessaire lorsque:

  1. Couleurs du thème de pré-cuisson - Utilisation de theme.fg() ou theme.bg() pour créer des chaînes stylisées stockées dans les composants enfants
  2. Mise en évidence de la syntaxe - Utilisation de highlightCode() qui applique des couleurs de syntaxe basées sur un thème
  3. Mises en page complexes - Création d'arborescences de composants enfants qui intègrent des couleurs de thème

Ce modèle n'est PAS nécessaire lorsque:

  1. Utilisation des rappels de thème - Passage de fonctions telles que (text) => theme.fg("accent", text) qui sont appelées lors du rendu
  2. Conteneurs simples - Regrouper simplement d'autres composants sans ajouter de contenu thématique
  3. Rendu sans état - Sortie thématique informatique à chaque appel render() (pas de mise en cache)

Modèles courants

Ces modèles couvrent les besoins d’interface utilisateur les plus courants dans les extensions. Copiez ces modèles au lieu de créer à partir de zéro.

Modèle 1: boîte de dialogue de sélection (SelectList)

Pour permettre aux utilisateurs de choisir parmi une liste d'options. Utilisez SelectList de @earendil-works/pi-tui avec DynamicBorder pour le cadrage.

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";

pi.registerCommand("pick", {
  handler: async (_args, ctx) => {
    const items: SelectItem[] = [
      { value: "opt1", label: "Option 1", description: "First option" },
      { value: "opt2", label: "Option 2", description: "Second option" },
      { value: "opt3", label: "Option 3" },  // description is optional
    ];

    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
      const container = new Container();

      // Top border
      container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

      // Title
      container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0));

      // SelectList with theme
      const selectList = new SelectList(items, Math.min(items.length, 10), {
        selectedPrefix: (t) => theme.fg("accent", t),
        selectedText: (t) => theme.fg("accent", t),
        description: (t) => theme.fg("muted", t),
        scrollInfo: (t) => theme.fg("dim", t),
        noMatch: (t) => theme.fg("warning", t),
      });
      selectList.onSelect = (item) => done(item.value);
      selectList.onCancel = () => done(null);
      container.addChild(selectList);

      // Help text
      container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));

      // Bottom border
      container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

      return {
        render: (w) => container.render(w),
        invalidate: () => container.invalidate(),
        handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
      };
    });

    if (result) {
      ctx.ui.notify(`Selected: ${result}`, "info");
    }
  },
});

Exemples: preset.ts, tools.ts

Modèle 2: opération asynchrone avec annulation (BorderedLoader)

Pour les opérations qui prennent du temps et doivent être annulables. BorderedLoader affiche une roulette et gère l'échappement pour annuler.

import { BorderedLoader } from "@earendil-works/pi-coding-agent";

pi.registerCommand("fetch", {
  handler: async (_args, ctx) => {
    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
      const loader = new BorderedLoader(tui, theme, "Fetching data...");
      loader.onAbort = () => done(null);

      // Do async work
      fetchData(loader.signal)
        .then((data) => done(data))
        .catch(() => done(null));

      return loader;
    });

    if (result === null) {
      ctx.ui.notify("Cancelled", "info");
    } else {
      ctx.ui.setEditorText(result);
    }
  },
});

Exemples: qna.ts, handoff.ts

Modèle 3: Paramètres/Bascules (Liste des paramètres)

Pour basculer plusieurs paramètres. Utilisez SettingsList de @earendil-works/pi-tui avec getSettingsListTheme().

import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";

pi.registerCommand("settings", {
  handler: async (_args, ctx) => {
    const items: SettingItem[] = [
      { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
      { id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
    ];

    await ctx.ui.custom((_tui, theme, _kb, done) => {
      const container = new Container();
      container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));

      const settingsList = new SettingsList(
        items,
        Math.min(items.length + 2, 15),
        getSettingsListTheme(),
        (id, newValue) => {
          // Handle value change
          ctx.ui.notify(`${id} = ${newValue}`, "info");
        },
        () => done(undefined),  // On close
        { enableSearch: true }, // Optional: enable fuzzy search by label
      );
      container.addChild(settingsList);

      return {
        render: (w) => container.render(w),
        invalidate: () => container.invalidate(),
        handleInput: (data) => settingsList.handleInput?.(data),
      };
    });
  },
});

Exemples: tools.ts

Modèle 4: indicateur d'état persistant

Afficher l'état dans le pied de page qui persiste dans les rendus. Bon pour les indicateurs de mode.

// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));

// Clear status
ctx.ui.setStatus("my-ext", undefined);

Exemples: status-line.ts, plan-mode/index.ts, preset.ts

Modèle 4b: personnalisation des indicateurs de travail

Personnalisez l'indicateur de travail en ligne affiché pendant que pi diffuse une réponse.

// Static indicator
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });

// Custom animated indicator
ctx.ui.setWorkingIndicator({
  frames: [
    ctx.ui.theme.fg("dim", "·"),
    ctx.ui.theme.fg("muted", "•"),
    ctx.ui.theme.fg("accent", "●"),
    ctx.ui.theme.fg("muted", "•"),
  ],
  intervalMs: 120,
});

// Hide the indicator entirely
ctx.ui.setWorkingIndicator({ frames: [] });

// Restore pi's default spinner
ctx.ui.setWorkingIndicator();

Cela n’affecte que l’indicateur de fonctionnement normal du streaming. Les chargeurs de compactage et de nouvelle tentative conservent leur style intégré. Les cadres personnalisés sont rendus textuellement, les extensions doivent donc ajouter leurs propres couleurs si nécessaire.

Exemples: working-indicator.ts

Modèle 5: Éditeur de widgets au-dessus/en dessous

Afficher le contenu persistant au-dessus ou en dessous de l'éditeur d'entrée. Bon pour les listes de tâches, les progrès.

// Simple string array (above editor by default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);

// Render below the editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });

// Or with theme
ctx.ui.setWidget("my-widget", (_tui, theme) => {
  const lines = items.map((item, i) =>
    item.done
      ? theme.fg("success", "✓ ") + theme.fg("muted", item.text)
      : theme.fg("dim", "○ ") + item.text
  );
  return {
    render: () => lines,
    invalidate: () => {},
  };
});

// Clear
ctx.ui.setWidget("my-widget", undefined);

Exemples: plan-mode/index.ts

Modèle 6: pied de page personnalisé

Remplacez le pied de page. footerData expose des données qui ne seraient autrement pas accessibles aux extensions.

ctx.ui.setFooter((tui, theme, footerData) => ({
  invalidate() {},
  render(width: number): string[] {
    // footerData.getGitBranch(): string | null
    // footerData.getExtensionStatuses(): ReadonlyMap<string, string>
    return [`${ctx.model?.id} (${footerData.getGitBranch() || "no git"})`];
  },
  dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive
}));

ctx.ui.setFooter(undefined); // restore default

Statistiques des jetons disponibles via ctx.sessionManager.getBranch() et ctx.model.

Exemples: custom-footer.ts

Modèle 7: Éditeur personnalisé (mode vim, etc.)

Remplacez l'éditeur d'entrée principal par une implémentation personnalisée. Utile pour l'édition modale (vim), différentes combinaisons de touches (emacs) ou la gestion des entrées spécialisées.

import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";

type Mode = "normal" | "insert";

class VimEditor extends CustomEditor {
  private mode: Mode = "insert";

  handleInput(data: string): void {
    // Escape: switch to normal mode, or pass through for app handling
    if (matchesKey(data, "escape")) {
      if (this.mode === "insert") {
        this.mode = "normal";
        return;
      }
      // In normal mode, escape aborts agent (handled by CustomEditor)
      super.handleInput(data);
      return;
    }

    // Insert mode: pass everything to CustomEditor
    if (this.mode === "insert") {
      super.handleInput(data);
      return;
    }

    // Normal mode: vim-style navigation
    switch (data) {
      case "i": this.mode = "insert"; return;
      case "h": super.handleInput("\x1b[D"); return; // Left
      case "j": super.handleInput("\x1b[B"); return; // Down
      case "k": super.handleInput("\x1b[A"); return; // Up
      case "l": super.handleInput("\x1b[C"); return; // Right
    }
    // Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars
    if (data.length === 1 && data.charCodeAt(0) >= 32) return;
    super.handleInput(data);
  }

  render(width: number): string[] {
    const lines = super.render(width);
    // Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
    if (lines.length > 0) {
      const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
      const lastLine = lines[lines.length - 1]!;
      // Pass "" as ellipsis to avoid adding "..." when truncating
      lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, "") + label;
    }
    return lines;
  }
}

export default function (pi: ExtensionAPI) {
  pi.on("session_start", (_event, ctx) => {
    // Factory receives the TUI, theme, and keybindings from the app
    ctx.ui.setEditorComponent((tui, theme, keybindings) =>
      new VimEditor(tui, theme, keybindings)
    );
  });
}

Points clés:

  • Étendez CustomEditor (pas la base Editor) pour obtenir les raccourcis clavier de l'application (échappement pour abandonner, ctrl+d pour quitter, changement de modèle, etc.)
  • Appelez le super.handleInput(data) pour les clés que vous ne gérez pas
  • Modèle d'usine: setEditorComponent reçoit une fonction d'usine qui obtient tui, theme et keybindings
  • Passez undefined pour restaurer l'éditeur par défaut: ctx.ui.setEditorComponent(undefined)

Exemples: modal-editor.ts

Règles clés

  1. Toujours utiliser le thème du rappel - N'importez pas le thème directement. Utilisez theme à partir du rappel ctx.ui.custom((tui, theme, keybindings, done) =>...).

  2. Toujours taper le paramètre de couleur DynamicBorder - Écrivez (s: string) => theme.fg("accent", s), pas (s) => theme.fg("accent", s).

  3. Appelez tui.requestRender() après un changement d'état - Dans handleInput, appelez tui.requestRender() après la mise à jour de l'état.

  4. Renvoyer l'objet à trois méthodes - Les composants personnalisés ont besoin de { render, invalidate, handleInput }.

  5. Utiliser les composants existants - SelectList, SettingsList, BorderedLoader couvrent 90 % des cas. Ne les reconstruisez pas.

Exemples