You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/09/08 15:09:29 UTC

(maka) branch main updated: feat(cli): add searchable session picker (#5027)

This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new 21d1609f4f feat(cli): add searchable session picker (#5027)
21d1609f4f is described below

commit 21d1609f4fce5f349045adbda0364576df81400d
Author: Zhang <96...@users.noreply.github.com>
AuthorDate: Tue Sep 8 23:09:24 2026 +0800

    feat(cli): add searchable session picker (#5027)
    
    * feat(cli): add searchable session picker
    
    Generated-by: Codex
    
    * fix(cli): preserve session picker search selection
    
    Generated-by: Codex
    
    * chore(cli): remove stale session copy allowances
    
    Generated-by: Codex
    
    * fix(cli): localize session picker and restore adaptive width
    
    Generated-by: Codex
---
 packages/cli/src/__tests__/pi-tui-runner.test.ts | 111 ++++++++++++++++++-
 packages/cli/src/pi-tui-pickers.ts               | 134 +++++++++++++++++++++++
 packages/cli/src/pi-tui-runner.ts                |  80 +++++++++-----
 packages/cli/src/tui-copy-catalog.ts             |  18 +++
 scripts/check-tui-copy.mjs                       |   2 -
 5 files changed, 311 insertions(+), 34 deletions(-)

diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts
index b158b95953..64b8ae6a0c 100644
--- a/packages/cli/src/__tests__/pi-tui-runner.test.ts
+++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts
@@ -26,7 +26,7 @@ import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import { setTimeout as delay } from 'node:timers/promises';
 import { describe, test } from 'node:test';
-import { visibleWidth } from '@earendil-works/pi-tui';
+import { TuiMainScreen, visibleWidth } from '@earendil-works/pi-tui';
 import { SHELL_RUN_UPDATE_BUFFER_MAX_ENTRIES } from '@maka/core/shell-run-result';
 import { resolveConnectionModelCatalog } from '@maka/core/model-catalog';
 import { deriveConnectionSlug } from '@maka/core/llm-connections';
@@ -66,7 +66,11 @@ import { skillInvocationBlockedMessage } from '../session-driver.js';
 import { SafeBoundaryResumeParkedError } from '../runtime-host-session-driver.js';
 import { listApiKeyOnboardableProviders } from '../onboarding-catalog.js';
 import { projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js';
-import { modelChoiceConnectionLabels } from '../pi-tui-pickers.js';
+import {
+  getTuiPickerCopy,
+  modelChoiceConnectionLabels,
+  SessionSearchOverlay,
+} from '../pi-tui-pickers.js';
 import type {
   MakaOnboardingSurface,
   MakaPiTuiTurnActivitySurface,
@@ -241,6 +245,54 @@ function savedOnboardingRefreshFailed(connectionId = 'saved-connection-id'): Onb
 }
 
 describe('Maka Pi TUI runner', () => {
+  test('localizes the session search title and scope in every supported locale', () => {
+    for (const [locale, title, current, all] of [
+      ['en', 'Resume Session', 'Current', 'All'],
+      ['zh-CN', '恢复会话', '当前目录', '全部'],
+      ['zh-TW', '恢復會話', '目前目錄', '全部'],
+    ] as const) {
+      const copy = getTuiPickerCopy(locale);
+      const picker = new SessionSearchOverlay(new TuiMainScreen(new FakeTerminal()), {
+        locale,
+        choices: [],
+        scopeLabel: copy.sessionScopeCurrent,
+        onSelect() {},
+        onCancel() {},
+        onToggleScope() {},
+      });
+      assert.equal(plainTerminalOutput(picker.render(100)[0]!).trim(), `${title} ${current}`);
+      picker.updateChoices([], copy.sessionScopeAll);
+      assert.equal(plainTerminalOutput(picker.render(100)[0]!).trim(), `${title} ${all}`);
+    }
+  });
+
+  test('session names use wide viewports and keep selection across resizing', () => {
+    const name = 'Investigate runtime-host multi-account session isolation and lifecycle';
+    let selected: string | undefined;
+    const picker = new SessionSearchOverlay(new TuiMainScreen(new FakeTerminal()), {
+      locale: 'en',
+      choices: ['first', 'second'].map((value) => ({
+        item: { value, label: `${name} ${value}`, description: '/repo · model' },
+        searchText: name.toLowerCase(),
+      })),
+      scopeLabel: 'Current',
+      onSelect(item) {
+        selected = item.value;
+      },
+      onCancel() {},
+      onToggleScope() {},
+    });
+    assert.ok(plainTerminalOutput(picker.render(160).join('\n')).includes(`${name} second`));
+    picker.handleInput('\x1b[B');
+    for (const width of [40, 80, 160]) {
+      const lines = picker.render(width);
+      assert.ok(lines.every((line) => visibleWidth(line) <= width));
+    }
+    assert.ok(plainTerminalOutput(picker.render(160).join('\n')).includes(`${name} second`));
+    picker.handleInput('\r');
+    assert.equal(selected, 'second');
+  });
+
   test('/help uses the resolved locale for headings and command descriptions', async () => {
     const terminal = new FakeTerminal();
     const driver = new SlashCommandDriver();
@@ -4996,6 +5048,57 @@ describe('Maka Pi TUI runner', () => {
     await run;
   });
 
+  test('searches sessions and preserves the query across Current/All scope', async () => {
+    const terminal = new FakeTerminal(160, 30);
+    const driver = new SlashCommandDriver([
+      {
+        ...fakeSessionSummary('current-session', '/repo', 'Current chat'),
+        model: 'model-a',
+        llmConnectionSlug: 'conn-a',
+      },
+      {
+        ...fakeSessionSummary('current-second', '/repo', 'Current second'),
+        model: 'model-a',
+        llmConnectionSlug: 'conn-a',
+      },
+      {
+        ...fakeSessionSummary('other-session', '/other/repo', 'Other chat'),
+        model: 'model-b',
+        llmConnectionSlug: 'conn-b',
+      },
+    ]);
+    const run = runMakaPiTui({
+      title: 'Maka',
+      driver,
+      cwd: '/repo',
+      model: 'model-a',
+      connectionSlug: 'conn-a',
+      permissionMode: 'ask',
+      terminal,
+    });
+
+    terminal.input('/session');
+    terminal.input('\r');
+    await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Current chat'));
+    terminal.input('\x1b[B');
+    await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('→ Current second'));
+    terminal.input('\t');
+    await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('→ Current second'));
+    terminal.input('Other');
+    await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Other chat'));
+    terminal.input('\t');
+    await waitFor(() =>
+      plainTerminalOutput(terminal.screenOutput()).includes('No matching sessions'),
+    );
+    terminal.input('\t');
+    await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Other chat'));
+    assert.match(plainTerminalOutput(terminal.screenOutput()), /\/other\/repo/);
+    terminal.input('\x1b');
+
+    exitMaka(terminal);
+    await run;
+  });
+
   test('shows localized live status badges in the Session picker', async () => {
     const terminal = new FakeTerminal(160, 30);
     const driver = new SlashCommandDriver([
@@ -5106,6 +5209,10 @@ describe('Maka Pi TUI runner', () => {
     await waitFor(() => plainTerminalOutput(terminal.output()).includes('Prior parser work'));
     await waitFor(() => plainTerminalOutput(terminal.output()).includes('resume from Claude Code'));
 
+    // Foreign cwd participates in the advertised path search.
+    terminal.input('repo');
+    await waitFor(() => plainTerminalOutput(terminal.output()).includes('Prior parser work'));
+
     terminal.input('\r');
     await waitFor(() => readDigestCalls === 1);
     await waitFor(() => driver.startNewSessionCalls === 1);
diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts
index cf086d6c4a..b57611a640 100644
--- a/packages/cli/src/pi-tui-pickers.ts
+++ b/packages/cli/src/pi-tui-pickers.ts
@@ -23,6 +23,7 @@ import {
   Key,
   SelectList,
   decodeKittyPrintable,
+  isKeyRelease,
   isKeyRepeat,
   matchesKey,
   truncateToWidth,
@@ -70,6 +71,11 @@ interface TuiPickerCopy {
   readonly modelSearchHint: string;
   readonly searchLabel: string;
   readonly noMatchingModels: string;
+  readonly resumeSessionTitle: string;
+  readonly sessionScopeCurrent: string;
+  readonly sessionScopeAll: string;
+  readonly sessionSearchHint: string;
+  readonly noMatchingSessions: string;
   readonly selectPickerHint: string;
   readonly providerConfigured: string;
   readonly addAccount: string;
@@ -802,6 +808,134 @@ function matchesModelChoice(choice: ModelChoice, query: string): boolean {
   return false;
 }
 
+export interface SessionSearchChoice {
+  item: SelectItem;
+  searchText: string;
+}
+
+export interface SessionSearchOverlayInput {
+  locale: UiLocale;
+  choices: readonly SessionSearchChoice[];
+  scopeLabel: string;
+  onSelect: (item: SelectItem) => void;
+  onCancel: () => void;
+  onToggleScope: () => void;
+}
+
+export class SessionSearchOverlay implements Component {
+  private renderWidth = 0;
+  private readonly searchEditor: Editor;
+  private readonly copy: TuiPickerCopy;
+  private choices: readonly SessionSearchChoice[];
+  private filtered: readonly SessionSearchChoice[];
+  private list: SelectList;
+  private selectedValue: string | undefined;
+  private scopeLabel: string;
+
+  constructor(
+    private readonly tui: TUI,
+    private readonly input: SessionSearchOverlayInput,
+  ) {
+    this.copy = getTuiPickerCopy(input.locale);
+    this.choices = input.choices;
+    this.filtered = input.choices;
+    this.scopeLabel = input.scopeLabel;
+    this.list = this.buildList();
+    this.searchEditor = new Editor(tui, editorTheme(), { paddingX: 0 });
+    this.searchEditor.onChange = (text) => this.applyQuery(text);
+  }
+
+  updateChoices(choices: readonly SessionSearchChoice[], scopeLabel: string): void {
+    this.choices = choices;
+    this.scopeLabel = scopeLabel;
+    this.applyQuery(this.searchEditor.getText());
+  }
+
+  private buildList(): SelectList {
+    const list = new SelectList(
+      this.filtered.map(({ item }) => item),
+      10,
+      selectListTheme(),
+      {
+        minPrimaryColumnWidth: 20,
+        maxPrimaryColumnWidth: Math.max(20, this.renderWidth - 30),
+      },
+    );
+    const selectedIndex = this.filtered.findIndex(({ item }) => item.value === this.selectedValue);
+    if (selectedIndex >= 0) list.setSelectedIndex(selectedIndex);
+    list.onSelectionChange = (item) => {
+      this.selectedValue = item.value;
+    };
+    list.onSelect = (item) => {
+      this.selectedValue = item.value;
+      this.input.onSelect(item);
+    };
+    list.onCancel = () => this.input.onCancel();
+    return list;
+  }
+
+  private applyQuery(text: string): void {
+    const query = text.trim().toLocaleLowerCase();
+    this.filtered = query
+      ? this.choices.filter((choice) => choice.searchText.includes(query))
+      : this.choices;
+    this.list = this.buildList();
+  }
+
+  invalidate(): void {
+    this.searchEditor.invalidate();
+    this.list.invalidate();
+  }
+
+  handleInput(data: string): void {
+    if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
+      this.input.onCancel();
+      return;
+    }
+    if (matchesKey(data, Key.tab) && !isKeyRelease(data) && !isKeyRepeat(data)) {
+      this.input.onToggleScope();
+      return;
+    }
+    if (matchesKey(data, Key.up) || matchesKey(data, Key.down) || matchesKey(data, Key.enter)) {
+      if (matchesKey(data, Key.enter) && isKeyRepeat(data)) return;
+      this.list.handleInput(data);
+      return;
+    }
+    this.searchEditor.handleInput(data);
+  }
+
+  render(width: number): string[] {
+    const safeWidth = Math.max(1, width);
+    if (safeWidth !== this.renderWidth) {
+      this.renderWidth = safeWidth;
+      this.list = this.buildList();
+    }
+    this.searchEditor.focused = true;
+    return [
+      padLine(`${this.copy.resumeSessionTitle} ${ansi.accent(this.scopeLabel)}`, safeWidth),
+      padLine(ansi.dim(this.copy.sessionSearchHint), safeWidth),
+      padLine('', safeWidth),
+      ...this.renderFieldRow(safeWidth),
+      padLine('', safeWidth),
+      ...(this.filtered.length === 0
+        ? [padLine(ansi.dim(this.copy.noMatchingSessions), safeWidth)]
+        : this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth))),
+      padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth),
+    ];
+  }
+
+  private renderFieldRow(width: number): string[] {
+    const prefix = `${this.copy.searchLabel} `;
+    const contentWidth = Math.max(1, width - visibleWidth(prefix));
+    const editorLines = this.searchEditor.render(contentWidth).slice(1, -1);
+    return editorLines.length > 0
+      ? editorLines.map((line, index) =>
+          padLine(`${index === 0 ? prefix : ' '.repeat(prefix.length)}${line}`, width),
+        )
+      : [padLine(prefix, width)];
+  }
+}
+
 export interface ModelSearchOverlayInput {
   locale: UiLocale;
   choices: readonly ModelChoice[];
diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts
index 99d564b860..d79399e7d2 100644
--- a/packages/cli/src/pi-tui-runner.ts
+++ b/packages/cli/src/pi-tui-runner.ts
@@ -18,7 +18,6 @@
  */
 
 import { randomUUID } from 'node:crypto';
-import { basename } from 'node:path';
 import {
   Key,
   ProcessTerminal,
@@ -167,6 +166,7 @@ import {
   MakaAutocompleteProvider,
   DirectoryPickerOverlay,
   ModelSearchOverlay,
+  SessionSearchOverlay,
   OnboardingWizard,
   PickerOverlay,
   UserQuestionOverlay,
@@ -178,6 +178,7 @@ import {
   skillPickerItems,
   thinkingLevelPickerItems,
   type MakaSlashCommand,
+  type SessionSearchChoice,
 } from './pi-tui-pickers.js';
 import { formatMakaResumeCommand } from './cli-invocation.js';
 import {
@@ -2812,21 +2813,22 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
         foreignByValue.set(`foreign:${summary.source}:${summary.id}`, summary);
       }
     }
+    let overlay: OverlayHandle | undefined;
+    let sessionSearch: SessionSearchOverlay | undefined;
     const renderScope = (): void => {
       const visibleSessions =
         sessionListScope === 'current'
           ? projectedSessions.filter(({ session }) => session.cwd === cwd)
           : projectedSessions;
-      const items: SelectItem[] = visibleSessions.map(({ session, depth }) => {
+      const choices: SessionSearchChoice[] = visibleSessions.map(({ session, depth }) => {
         const state = availability.get(session.id);
         const statusBadge = sessionStatusBadge(session, locale);
         const statusDetail = statusBadge ? ` · ${statusBadge}` : '';
-        const location =
-          sessionListScope === 'all' && session.cwd ? ` ${basename(session.cwd)}` : '';
+        const location = sessionListScope === 'all' && session.cwd ? ` ${session.cwd}` : '';
         const childDetail = session.subagentRuntime
           ? ` subagent:${session.subagentRuntime.profile}`
           : '';
-        return {
+        const item = {
           value: session.id,
           label: `${depth > 0 ? `${'  '.repeat(depth - 1)}↳ ` : ''}${session.name || session.id}`,
           description:
@@ -2834,26 +2836,38 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
               ? `${shortSessionId(session.id)}${statusDetail} ${state.reason}`
               : `${shortSessionId(session.id)}${statusDetail}${location}${childDetail} ${session.llmConnectionSlug} ${session.model}`,
         };
+        return {
+          item,
+          searchText: [
+            session.name,
+            session.id,
+            session.cwd,
+            session.model,
+            session.llmConnectionSlug,
+          ]
+            .filter(Boolean)
+            .join(' ')
+            .toLocaleLowerCase(),
+        };
       });
       // Foreign sessions are cwd-scoped; show them in both scope views (they
       // belong to this project) so a Tab toggle never makes them vanish.
       for (const [value, summary] of foreignByValue) {
-        items.push({
-          value,
-          label: summary.title,
-          description: `↩ resume from ${foreignSourceLabel(summary.source)}`,
+        choices.push({
+          item: {
+            value,
+            label: summary.title,
+            description: `↩ resume from ${foreignSourceLabel(summary.source)}`,
+          },
+          searchText:
+            `${summary.title} ${summary.id} ${summary.cwd} ${summary.source}`.toLocaleLowerCase(),
         });
       }
-      const list = new SelectList(items, 10, selectListTheme(), {
-        minPrimaryColumnWidth: 20,
-        maxPrimaryColumnWidth: Math.max(20, terminal.columns - 30),
-      });
-      let overlay: OverlayHandle | undefined;
       const closeOverlay = () => {
         sessionPickerOverlayOpen = false;
         overlay?.hide();
       };
-      list.onSelect = (item) => {
+      const onSelect = (item: SelectItem) => {
         const foreign = foreignByValue.get(item.value);
         if (foreign) {
           closeOverlay();
@@ -2864,22 +2878,28 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
         closeOverlay();
         void goToSession(item.value);
       };
-      list.onCancel = () => closeOverlay();
+      const scopeLabel =
+        sessionListScope === 'current'
+          ? pickerCopy.sessionScopeCurrent
+          : pickerCopy.sessionScopeAll;
+      if (sessionSearch) {
+        sessionSearch.updateChoices(choices, scopeLabel);
+        sessionSearch.invalidate();
+        return;
+      }
+      sessionSearch = new SessionSearchOverlay(tui, {
+        locale,
+        choices,
+        scopeLabel,
+        onSelect,
+        onCancel: closeOverlay,
+        onToggleScope: () => {
+          sessionListScope = sessionListScope === 'current' ? 'all' : 'current';
+          renderScope();
+        },
+      });
       sessionPickerOverlayOpen = true;
-      overlay = showBottomPicker(
-        new PickerOverlay(list, {
-          title: 'Resume Session',
-          rightLabel: sessionListScope === 'current' ? 'Current' : 'All',
-          hint: 'Tab scope · ↑↓ move · Enter select · Esc close',
-          onInput: (data) => {
-            if (!matchesKey(data, Key.tab) || isKeyRelease(data) || isKeyRepeat(data)) return false;
-            sessionListScope = sessionListScope === 'current' ? 'all' : 'current';
-            overlay?.hide();
-            renderScope();
-            return true;
-          },
-        }),
-      );
+      overlay = showBottomPicker(sessionSearch);
     };
     renderScope();
   };
diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts
index 1a75c1dcea..557ed82935 100644
--- a/packages/cli/src/tui-copy-catalog.ts
+++ b/packages/cli/src/tui-copy-catalog.ts
@@ -623,6 +623,12 @@ export const TUI_COPY_RESOURCES = {
         'Search models / providers / connections · ↑↓ select · Enter confirm · Esc cancel',
       searchLabel: 'Search',
       noMatchingModels: 'No matching models',
+      resumeSessionTitle: 'Resume Session',
+      sessionScopeCurrent: 'Current',
+      sessionScopeAll: 'All',
+      sessionSearchHint:
+        'Search name / id / path / model / connection · Tab scope · ↑↓ select · Enter open · Esc close',
+      noMatchingSessions: 'No matching sessions',
       selectPickerHint: '↑↓ select · Enter confirm · Esc close',
       providerConfigured: 'configured',
       addAccount: 'add account',
@@ -722,6 +728,12 @@ export const TUI_COPY_RESOURCES = {
       modelSearchHint: '搜索模型 / 服务商 / 连接 · ↑↓ 选择 · Enter 确认 · Esc 取消',
       searchLabel: '搜索',
       noMatchingModels: '没有匹配的模型',
+      resumeSessionTitle: '恢复会话',
+      sessionScopeCurrent: '当前目录',
+      sessionScopeAll: '全部',
+      sessionSearchHint:
+        '搜索名称 / ID / 路径 / 模型 / 连接 · Tab 切换范围 · ↑↓ 选择 · Enter 打开 · Esc 关闭',
+      noMatchingSessions: '没有匹配的会话',
       selectPickerHint: '↑↓ 选择 · Enter 确认 · Esc 关闭',
       providerConfigured: '已设置',
       addAccount: '添加账号',
@@ -811,6 +823,12 @@ export const TUI_COPY_RESOURCES = {
       modelSearchHint: '搜尋模型 / 服務商 / 連線 · ↑↓ 選擇 · Enter 確認 · Esc 取消',
       searchLabel: '搜尋',
       noMatchingModels: '沒有符合的模型',
+      resumeSessionTitle: '恢復會話',
+      sessionScopeCurrent: '目前目錄',
+      sessionScopeAll: '全部',
+      sessionSearchHint:
+        '搜尋名稱 / ID / 路徑 / 模型 / 連線 · Tab 切換範圍 · ↑↓ 選取 · Enter 開啟 · Esc 關閉',
+      noMatchingSessions: '沒有符合的會話',
       selectPickerHint: '↑↓ 選擇 · Enter 確認 · Esc 關閉',
       providerConfigured: '已設定',
       addAccount: '新增帳號',
diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs
index 89e82365f1..83d7a206f8 100644
--- a/scripts/check-tui-copy.mjs
+++ b/scripts/check-tui-copy.mjs
@@ -101,8 +101,6 @@ export const ALLOWED_VISIBLE_LITERALS = {
     'Recap: ${…}',
     'Compacting context…',
     'Resuming from the latest safe boundary…',
-    'Resume Session',
-    'Tab scope · ↑↓ move · Enter select · Esc close',
     'Permissions: ${…}',
     'Keep Auto',
     'Turn on full access',


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org