Pi 구성, 확장, 플랫폼 설정 및 API 참조.

TUI 구성품

pi는 TUI 구성요소를 생성할 수 있습니다. 귀하의 사용 사례에 맞게 구축하도록 요청하세요.

Extensions 및 맞춤 도구는 대화형 사용자 인터페이스를 위한 맞춤 TUI 구성 요소를 렌더링할 수 있습니다. 이 페이지에서는 구성 요소 시스템과 사용 가능한 빌딩 블록을 다룹니다.

출처: @earendil-works/pi-tui

구성 요소 인터페이스

모든 구성요소는 다음을 구현합니다.

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}
방법 설명
render(width) 문자열 배열을 반환합니다(한 줄에 하나씩). 각 줄은 **width**을 초과할 수 없습니다.
handleInput?(data) 구성 요소에 포커스가 있을 때 키보드 입력을 받습니다.
wantsKeyRelease? true인 경우 구성요소는 키 릴리스 이벤트(Kitty 프로토콜)를 수신합니다. 기본값: 거짓.
invalidate() 캐시된 렌더링 상태를 지웁니다. 테마 변경 시 호출됩니다.

TUI는 렌더링된 각 줄의 끝에 전체 SGR 재설정과 OSC 8 재설정을 추가합니다. 스타일은 줄을 넘어 전달되지 않습니다. 스타일을 사용하여 여러 줄로 된 텍스트를 내보내는 경우 줄당 스타일을 다시 적용하거나 wrapTextWithAnsi()를 사용하여 줄바꿈된 각 줄에 대해 스타일이 유지되도록 합니다.

포커스 가능 인터페이스(IME 지원)

텍스트 커서를 표시하고 IME(입력 방법 편집기) 지원이 필요한 구성 요소는 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}`];
  }
}

Focusable 구성 요소에 포커스가 있는 경우 TUI:

  1. 구성요소에 focused = true를 설정합니다.
  2. CURSOR_MARKER(너비가 0인 APC 이스케이프 시퀀스)에 대해 렌더링된 출력을 검사합니다.
  3. 해당 위치에 하드웨어 터미널 커서를 배치합니다.
  4. showHardwareCursor가 활성화된 경우에만 하드웨어 커서를 표시합니다.

커서는 기본적으로 숨겨져 있습니다. 이렇게 하면 숨겨진 커서가 있는 IME 후보 창을 추적하는 터미널에 대한 하드웨어 커서의 위치를 ​​계속 지정하면서 가짜 커서 렌더링이 유지됩니다. 일부 터미널에는 IME 위치 지정을 위해 눈에 보이는 하드웨어 커서가 필요합니다. showHardwareCursor, setShowHardwareCursor(true) 또는 PI_HARDWARE_CURSOR=1로 활성화하세요. EditorInput 내장 구성요소는 이미 이 인터페이스를 구현합니다.

입력이 내장된 컨테이너 구성 요소

컨테이너 구성 요소(대화 상자, 선택기 등)에 Input 또는 Editor 하위 항목이 포함된 경우 컨테이너는 Focusable를 구현하고 포커스 상태를 하위 항목에 전파해야 합니다. 그렇지 않으면 하드웨어 커서가 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);
  }
}

이러한 전파가 없으면 IME(중국어, 일본어, 한국어 등)를 사용하여 입력하면 화면의 잘못된 위치에 후보 창이 표시됩니다.

구성요소 사용

확장 프로그램에서 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),
    })
  );
});

맞춤 도구에서 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...
}

오버레이

화면을 지우지 않고 기존 콘텐츠 위에 렌더링 구성 요소를 오버레이합니다. { overlay: true }에서 ctx.ui.custom()로 전달:

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

위치 지정 및 크기 조정에는 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
    },
  }
);

오버레이 포커스

집중된 가시 오버레이는 오버레이가 아닌 임시 UI 전반에 걸쳐 입력 소유권을 유지합니다. 오버레이가 { overlay: true } 없이 다른 ctx.ui.custom() 구성 요소를 열면 해당 대체 UI가 활성화된 동안 입력을 받습니다. 닫히면 초점이 맞춰진 오버레이가 입력을 회수할 수 있습니다.

표시 오버레이가 입력 소유를 중지해야 하는 경우 handle.unfocus()를 사용하고 TUI가 다른 표시 캡처 오버레이 또는 이전 포커스 대상으로 대체되도록 합니다. 오버레이가 계속 표시되는 동안 특정 구성요소가 입력을 받아야 하는 경우 handle.unfocus({ target })를 사용하세요. { target: null }를 전달하면 포커스가 다시 설정될 때까지 의도적으로 포커스된 구성 요소가 남지 ​​않습니다.

오버레이 수명주기

오버레이 구성요소는 닫히면 삭제됩니다. 참조를 재사용하지 마세요 - 새로운 인스턴스를 만드세요:

// 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

앵커, 여백, 스태킹, 반응형 가시성 및 애니메이션을 다루는 포괄적인 예는 overlay-qa-tests.ts를 참조하세요.

내장 구성요소

@earendil-works/pi-tui에서 가져오기:

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

텍스트

단어 줄바꿈이 포함된 여러 줄의 텍스트입니다.

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

상자

패딩과 배경색이 포함된 컨테이너입니다.

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));

컨테이너

하위 구성요소를 수직으로 그룹화합니다.

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

스페이서

빈 수직 공간.

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

Markdown

구문 강조를 사용하여 마크다운을 렌더링합니다.

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

영상

지원되는 터미널(Kitty, iTerm2, Ghostty, WezTerm, Warp)에서 이미지를 렌더링합니다.

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

키보드 입력

키 감지에는 matchesKey()를 사용하세요.

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
  }
}

주요 식별자(자동 완성 또는 문자열 리터럴의 경우 Key.* 사용):

  • 기본 키: Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end
  • 화살표 키: Key.up, Key.down, Key.left, Key.right
  • 수정자 포함: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
  • 문자열 형식도 작동합니다: "enter", "ctrl+c", "shift+tab", "ctrl+shift+p"

선 너비

필수: render()의 각 줄은 width 매개변수를 초과해서는 안 됩니다.

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

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

유용:

  • visibleWidth(str) - 디스플레이 너비 가져오기(ANSI 코드 무시)
  • truncateToWidth(str, width, ellipsis?) - 선택적 줄임표로 자르기
  • wrapTextWithAnsi(str, width) - ANSI 코드를 보존하는 단어 줄 바꿈

사용자 정의 구성 요소 만들기

예: 대화형 선택기

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;
  }
}

확장에서의 사용법:

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");
    }
  }
});

테마

구성 요소는 스타일 지정을 위해 테마 개체를 허용합니다.

**renderCall/renderResult**에서는 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"));
}

전경 색상(theme.fg(color, text)):

범주 그림 물감
일반적인 text, accent, muted, dim
상태 success, error, warning
테두리 border, borderAccent, borderMuted
메시지 userMessageText, customMessageText, customMessageLabel
도구 toolTitle, toolOutput
차이점 toolDiffAdded, toolDiffRemoved, toolDiffContext
Markdown mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
통사론 syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
생각 thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax
모드 bashMode

배경 색상(theme.bg(color, text)):

selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg

Markdown의 경우 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);
}

맞춤 구성요소의 경우 자체 테마 인터페이스를 정의하세요.

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

디버그 로깅

PI_TUI_WRITE_LOG를 설정하여 stdout에 기록된 원시 ANSI 스트림을 캡처합니다.

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

성능

가능한 경우 렌더링된 출력을 캐시합니다.

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;
  }
}

상태가 변경되면 invalidate()를 호출한 다음 삽입된 tui.requestRender()를 사용하여 다시 렌더링을 트리거합니다.

무효화 및 테마 변경

테마가 변경되면 TUI는 모든 구성 요소에서 invalidate()를 호출하여 캐시를 지웁니다. 테마 변경사항이 적용되도록 구성요소는 invalidate()를 올바르게 구현해야 합니다.

문제

구성 요소가 테마 색상을 문자열(theme.fg(), theme.bg() 등을 통해)로 미리 굽고 캐시하는 경우 캐시된 문자열에는 이전 테마의 ANSI 이스케이프 코드가 포함됩니다. 구성 요소가 테마 콘텐츠를 별도로 저장하는 경우 단순히 렌더링 캐시를 지우는 것만으로는 충분하지 않습니다.

잘못된 접근 방식(테마 색상이 업데이트되지 않음):

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
}

해결책

테마 색상으로 콘텐츠를 빌드하는 구성 요소는 invalidate()가 호출될 때 해당 콘텐츠를 다시 빌드해야 합니다.

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
  }
}

패턴: 무효화 시 재구축

복잡한 콘텐츠가 포함된 구성요소의 경우:

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();
  }
}

이것이 중요한 경우

이 패턴은 다음과 같은 경우에 필요합니다.

  1. 사전 베이킹 테마 색상 - theme.fg() 또는 theme.bg()를 사용하여 하위 구성 요소에 저장된 스타일 문자열 생성
  2. 구문 강조 - 테마 기반 구문 색상을 적용하는 highlightCode() 사용
  3. 복잡한 레이아웃 - 테마 색상이 포함된 하위 구성요소 트리 구축

다음과 같은 경우에는 이 패턴이 필요하지 않습니다.

  1. 테마 콜백 사용 - 렌더링 중에 호출되는 (text) => theme.fg("accent", text)와 같은 함수 전달
  2. 간단한 컨테이너 - 테마 콘텐츠를 추가하지 않고 다른 구성요소를 그룹화하기만 하면 됩니다.
  3. 상태 비저장 렌더링 - 모든 render() 호출에서 테마 출력을 새로 계산합니다(캐싱 없음).

일반적인 패턴

이러한 패턴은 확장에서 가장 일반적인 UI 요구 사항을 다룹니다. 처음부터 만드는 대신 이 패턴을 복사하세요.

패턴 1: 선택 대화 상자(SelectList)

사용자가 옵션 목록에서 선택할 수 있도록 합니다. 프레이밍을 위해 @earendil-works/pi-tui에서 SelectListDynamicBorder와 함께 사용하세요.

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");
    }
  },
});

예: preset.ts, tools.ts

패턴 2: 취소를 사용한 비동기 작업(BorderedLoader)

시간이 걸리고 취소 가능해야 하는 작업의 경우. BorderedLoader는 스피너를 표시하고 취소를 위한 이스케이프를 처리합니다.

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);
    }
  },
});

예: qna.ts, handoff.ts

패턴 3: 설정/토글(SettingsList)

여러 설정을 전환합니다. getSettingsListTheme()와 함께 @earendil-works/pi-tuiSettingsList를 사용하세요.

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),
      };
    });
  },
});

예: tools.ts

패턴 4: 지속적인 상태 표시기

렌더링 전반에 걸쳐 지속되는 바닥글에 상태를 표시합니다. 모드 표시기에 적합합니다.

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

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

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

패턴 4b: 작업 표시기 사용자 정의

pi가 응답을 스트리밍하는 동안 표시되는 인라인 작업 표시기를 사용자 정의합니다.

// 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();

이는 일반 스트리밍 작동 표시에만 영향을 미칩니다. 압축 및 재시도 로더는 기본 제공 스타일을 유지합니다. 사용자 정의 프레임은 그대로 렌더링되므로 확장 프로그램은 필요할 때 자체 색상을 추가해야 합니다.

예: working-indicator.ts

패턴 5: 편집기 위/아래 위젯

입력 편집기 위 또는 아래에 영구 콘텐츠를 표시합니다. 할 일 목록, 진행에 좋습니다.

// 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);

예: plan-mode/index.ts

패턴 6: 사용자 정의 바닥글

바닥글을 교체하세요. footerData는 확장 프로그램에서 액세스할 수 없는 데이터를 노출합니다.

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

토큰 통계는 ctx.sessionManager.getBranch()ctx.model를 통해 확인할 수 있습니다.

예: custom-footer.ts

패턴 7: 사용자 정의 편집기(vim 모드 등)

기본 입력 편집기를 사용자 정의 구현으로 바꿉니다. 모달 편집(vim), 다양한 키 바인딩(emacs) 또는 특수 입력 처리에 유용합니다.

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)
    );
  });
}

핵심 사항:

  • CustomEditor(기본 Editor 아님)을 확장하여 앱 키 바인딩(중단하려면 이스케이프, 종료하려면 Ctrl+D, 모델 전환 등)을 가져옵니다.
  • **취급할 수 없는 키에 대해서는 super.handleInput(data)**로 전화하세요.
  • 팩토리 패턴: setEditorComponenttui, themekeybindings을 가져오는 팩토리 함수를 받습니다.
  • **기본 편집기를 복원하려면 undefined**를 전달하세요. ctx.ui.setEditorComponent(undefined)

예: modal-editor.ts

주요 규칙

  1. 항상 콜백에서 테마 사용 - 테마를 직접 가져오지 마세요. ctx.ui.custom((tui, theme, keybindings, done) =>...) 콜백에서 theme를 사용하세요.

  2. 항상 DynamicBorder 색상 매개변수를 입력하세요 - (s) => theme.fg("accent", s)가 아닌 (s: string) => theme.fg("accent", s)를 쓰세요.

  3. 상태 변경 후 tui.requestRender() 호출 - handleInput에서 상태 업데이트 후 tui.requestRender()를 호출합니다.

  4. 3가지 메소드 객체 반환 - 맞춤 구성요소에는 { render, invalidate, handleInput }가 필요합니다.

  5. 기존 구성요소 사용 - SelectList, SettingsList, BorderedLoader 사례의 90%를 다룹니다. 다시 빌드하지 마세요.