singularity-forge/docs/dev/pi-ui-tui/20-real-world-patterns-from-examples.md
Jeremy 872b0adb48 docs: reorganize into user-docs/ and dev/ subdirectories
Split flat docs/ into user-docs/ (guides, config, troubleshooting) and
dev/ (ADRs, architecture, extension guides, proposals). Updated
docs/README.md index to reflect new paths.
2026-04-10 09:25:31 -05:00

4.3 KiB

Real-World Patterns from Examples

Pattern: Selection Dialog with Borders

From preset.ts and tools.ts:

const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
  const container = new Container();
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
  container.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));

  const selectList = new SelectList(items, maxVisible, {
    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);
  container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
  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(); },
  };
});

Pattern: Game with Timer Loop

From snake.ts:

class SnakeComponent {
  private interval: ReturnType<typeof setInterval> | null = null;

  constructor(tui: { requestRender: () => void }, done: () => void) {
    this.interval = setInterval(() => {
      this.tick();       // Update game state
      this.version++;    // Bump render version
      tui.requestRender();  // Request re-render
    }, 100);
  }

  dispose(): void {
    if (this.interval) {
      clearInterval(this.interval);
      this.interval = null;
    }
  }

  // Call dispose() before calling done() to stop the timer
  handleInput(data: string): void {
    if (matchesKey(data, Key.escape)) {
      this.dispose();
      this.onClose();
    }
  }
}

Pattern: Async Operation with Spinner

From qna.ts:

const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
  const loader = new BorderedLoader(tui, theme, "Processing...");
  loader.onAbort = () => done(null);

  doAsyncWork(loader.signal)
    .then(data => done(data))
    .catch(() => done(null));

  return loader;
});

Pattern: Persistent Widget with Live Updates

From plan-mode/index.ts:

function updateUI(ctx: ExtensionContext): void {
  if (todoItems.length > 0) {
    const lines = todoItems.map(item => {
      if (item.completed) {
        return ctx.ui.theme.fg("success", "☑ ") +
               ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text));
      }
      return ctx.ui.theme.fg("muted", "☐ ") + item.text;
    });
    ctx.ui.setWidget("plan-todos", lines);
    ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${total}`));
  } else {
    ctx.ui.setWidget("plan-todos", undefined);
    ctx.ui.setStatus("plan-mode", undefined);
  }
}

Pattern: Multi-Tab Questionnaire

From questionnaire.ts:

// State: currentTab, optionIndex, inputMode, answers map
// Tab navigation with shift+tab / tab
// Option selection with up/down + enter
// "Type something" option that switches to embedded Editor
// Submit tab that shows summary of all answers
// Full renderCall and renderResult for LLM context display

From custom-footer.ts:

ctx.ui.setFooter((tui, theme, footerData) => ({
  invalidate() {},
  render(width: number): string[] {
    let input = 0, output = 0, cost = 0;
    for (const e of ctx.sessionManager.getBranch()) {
      if (e.type === "message" && e.message.role === "assistant") {
        const m = e.message as AssistantMessage;
        input += m.usage.input;
        output += m.usage.output;
        cost += m.usage.cost.total;
      }
    }
    const branch = footerData.getGitBranch();
    const left = theme.fg("dim", `↑${fmt(input)}${fmt(output)} $${cost.toFixed(3)}`);
    const right = theme.fg("dim", `${ctx.model?.id}${branch ? ` (${branch})` : ""}`);
    const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
    return [truncateToWidth(left + pad + right, width)];
  },
  dispose: footerData.onBranchChange(() => tui.requestRender()),
}));