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