diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 0414e7c6e9..944cbd9390 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3059,10 +3059,8 @@ describe('Maka Pi TUI runner', () => { terminal.input('/session'); terminal.input('\r'); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('↳ Local Read')); - assert.match( - plainTerminalOutput(terminal.screenOutput()), - /Local Read.*subagent:local_read active/, - ); + assert.match(plainTerminalOutput(terminal.screenOutput()), /Local Read.*subagent:local_read/); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\bactive\b/); terminal.input('\x1b[B'); terminal.input('\r'); @@ -3072,6 +3070,67 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('shows localized live status badges in the Session picker', async () => { + const terminal = new FakeTerminal(160, 30); + const driver = new SlashCommandDriver([ + { + ...fakeSessionSummary('session-running', '/repo', 'Running chat'), + status: 'running', + runningTurnIds: ['turn-live'], + }, + { + ...fakeSessionSummary('session-stale', '/repo', 'Stale running chat'), + status: 'running', + runningTurnIds: [], + }, + { + ...fakeSessionSummary('session-permission', '/repo', 'Permission chat'), + status: 'waiting_for_user', + blockedReason: 'permission_required', + }, + { + ...fakeSessionSummary('session-auth', '/repo', 'Auth chat'), + status: 'blocked', + blockedReason: 'auth', + }, + { + ...fakeSessionSummary('session-noise', '/repo', 'Retryable chat'), + status: 'blocked', + blockedReason: 'tool_failed', + }, + { + ...fakeSessionSummary('session-stopped', '/repo', 'Stopped chat'), + status: 'aborted', + }, + ]); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + locale: 'en', + terminal, + }); + + terminal.input('/session'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('needs permission')); + const output = plainTerminalOutput(terminal.screenOutput()); + assert.match(output, /Running chat.*session- · running/); + assert.doesNotMatch(output, /Stale running chat.* · running/); + assert.match(output, /Permission chat.*session- · needs permission/); + assert.match(output, /Auth chat.*session- · needs sign-in/); + assert.match(output, /Stopped chat.*session- · stopped/); + assert.match(output, /Retryable chat.*session-/); + assert.doesNotMatch(output, /waiting_for_user|permission_required|tool_failed/); + + terminal.input('\x1b'); + exitMaka(terminal); + await run; + }); + test('imports a foreign session from /session into a fresh handoff turn', async () => { const terminal = new FakeTerminal(); // No Maka sessions, so the only picker row is the foreign one. @@ -3544,11 +3603,16 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('shows a session without a cwd in All but prevents resuming it', async () => { + test('keeps live status visible for a session without a cwd but prevents resuming it', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver([ fakeSessionSummary('session-current', '/repo', 'Current chat'), - { ...fakeSessionSummary('session-legacy', '/repo', 'Legacy chat'), cwd: undefined }, + { + ...fakeSessionSummary('session-legacy', '/repo', 'Legacy chat'), + cwd: undefined, + status: 'running', + runningTurnIds: ['turn-live'], + }, ]); Object.defineProperty(driver, 'getSessionResumeAvailability', { value: undefined }); const run = runMakaPiTui({ @@ -3576,7 +3640,7 @@ describe('Maka Pi TUI runner', () => { assert.match( plainTerminalOutput(terminal.screenOutput()), - /Legacy chat.*Missing working directory/, + /Legacy chat.*session- · running Missing working directory/, ); terminal.input('\x1b'); diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 371a2b2416..d3fa199634 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -22,12 +22,34 @@ import { } from '@maka/runtime-host/protocol'; import { createRuntimeHostMakaSessionDriver, + runtimeHostSessionSummary, type RuntimeHostMakaSessionDriverInput, } from '../runtime-host-session-driver.js'; import { SkillInvocationBlockedError, type MakaAttachedSessionTurn } from '../session-driver.js'; import { WAIT_BUDGET_MS } from './tui-terminal-mock.js'; describe('Runtime Host Maka Session driver', () => { + test('maps authoritative live Turn ids into Session summaries', () => { + assert.deepEqual( + runtimeHostSessionSummary( + sessionProjection({ + status: 'running', + liveRunState: { schemaVersion: 1, runningTurnIds: ['turn-1', 'turn-2'] }, + }), + ).runningTurnIds, + ['turn-1', 'turn-2'], + ); + const knownEmpty = runtimeHostSessionSummary( + sessionProjection({ liveRunState: { schemaVersion: 1, runningTurnIds: [] } }), + ); + assert.equal(Object.hasOwn(knownEmpty, 'runningTurnIds'), true); + assert.deepEqual(knownEmpty.runningTurnIds, []); + assert.equal( + Object.hasOwn(runtimeHostSessionSummary(sessionProjection()), 'runningTurnIds'), + false, + ); + }); + test('keeps remote Session paths out of Client filesystem policy', async () => { const driver = createRuntimeHostMakaSessionDriver({ connection: new FakeConnection([]).value, diff --git a/packages/cli/src/__tests__/tui-session-status.test.ts b/packages/cli/src/__tests__/tui-session-status.test.ts new file mode 100644 index 0000000000..f9e7089f74 --- /dev/null +++ b/packages/cli/src/__tests__/tui-session-status.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { SessionSummary } from '@maka/core/session'; +import { sessionStatusBadge } from '../tui-session-status.js'; + +describe('TUI Session status badge', () => { + test('only marks running when the Runtime Host reports a live Turn', () => { + assert.equal(sessionStatusBadge(session({ status: 'running' }), 'en'), undefined); + assert.equal( + sessionStatusBadge(session({ status: 'running', runningTurnIds: [] }), 'en'), + undefined, + ); + assert.equal( + sessionStatusBadge(session({ status: 'running', runningTurnIds: ['turn-1'] }), 'en'), + 'running', + ); + }); + + test('distinguishes user questions from permission requests with localized copy', () => { + assert.equal( + sessionStatusBadge(session({ status: 'waiting_for_user' }), 'en'), + 'waiting for you', + ); + assert.equal( + sessionStatusBadge( + session({ status: 'waiting_for_user', blockedReason: 'permission_required' }), + 'en', + ), + 'needs permission', + ); + assert.equal(sessionStatusBadge(session({ status: 'waiting_for_user' }), 'zh'), '等你确认'); + }); + + test('marks only actionable blocked reasons and keeps resting rows unmarked', () => { + assert.equal( + sessionStatusBadge(session({ status: 'blocked', blockedReason: 'NO_REAL_CONNECTION' }), 'en'), + 'needs connection', + ); + assert.equal( + sessionStatusBadge(session({ status: 'blocked', blockedReason: 'auth' }), 'en'), + 'needs sign-in', + ); + assert.equal( + sessionStatusBadge( + session({ status: 'blocked', blockedReason: 'permission_required' }), + 'en', + ), + 'needs permission', + ); + for (const blockedReason of ['tool_failed', 'unknown'] as const) { + assert.equal( + sessionStatusBadge(session({ status: 'blocked', blockedReason }), 'en'), + undefined, + ); + } + assert.equal(sessionStatusBadge(session({ status: 'active' }), 'en'), undefined); + assert.equal(sessionStatusBadge(session({ status: 'aborted' }), 'en'), 'stopped'); + assert.equal(sessionStatusBadge(session({ status: 'aborted' }), 'zh'), '已中止'); + }); +}); + +function session( + overrides: Partial> = {}, +): Pick { + return { status: 'active', ...overrides }; +} diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 74da0e8664..4e481ee4d6 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -88,6 +88,7 @@ import { editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; import { createShellRunElapsedTicker } from './shell-run-elapsed-ticker.js'; import { createShellRunHydrationController } from './shell-run-hydration.js'; +import { sessionStatusBadge } from './tui-session-status.js'; import { AttentionController, DISABLE_FOCUS_REPORTING, @@ -2011,18 +2012,20 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { : projectedSessions; const items: SelectItem[] = 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 childDetail = session.subagentRuntime - ? ` subagent:${session.subagentRuntime.profile} ${session.status}` + ? ` subagent:${session.subagentRuntime.profile}` : ''; return { value: session.id, label: `${depth > 0 ? `${' '.repeat(depth - 1)}↳ ` : ''}${session.name || session.id}`, description: state?.available === false - ? `${shortSessionId(session.id)} ${state.reason}` - : `${shortSessionId(session.id)}${location}${childDetail} ${session.llmConnectionSlug} ${session.model}`, + ? `${shortSessionId(session.id)}${statusDetail} ${state.reason}` + : `${shortSessionId(session.id)}${statusDetail}${location}${childDetail} ${session.llmConnectionSlug} ${session.model}`, }; }); // Foreign sessions are cwd-scoped; show them in both scope views (they diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index e568ea2a0d..2882b6744f 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -1183,6 +1183,9 @@ export function runtimeHostSessionSummary(session: SessionCatalogProjection): Se status: session.status, ...(session.blockedReason === undefined ? {} : { blockedReason: session.blockedReason }), ...(session.statusUpdatedAt === undefined ? {} : { statusUpdatedAt: session.statusUpdatedAt }), + ...(session.liveRunState === undefined + ? {} + : { runningTurnIds: [...session.liveRunState.runningTurnIds] }), ...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }), ...(session.branchOfTurnId === undefined ? {} : { branchOfTurnId: session.branchOfTurnId }), ...(session.subagent === undefined ? {} : { subagent: session.subagent }), diff --git a/packages/cli/src/tui-session-status.ts b/packages/cli/src/tui-session-status.ts new file mode 100644 index 0000000000..409d4eaa2b --- /dev/null +++ b/packages/cli/src/tui-session-status.ts @@ -0,0 +1,70 @@ +import type { SessionSummary } from '@maka/core/session'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +interface TuiSessionStatusCopy { + readonly running: string; + readonly waitingForUser: string; + readonly permissionRequired: string; + readonly connectionRequired: string; + readonly signInRequired: string; + readonly stopped: string; +} + +const TUI_SESSION_STATUS_COPY = { + zh: { + running: '进行中', + waitingForUser: '等你确认', + permissionRequired: '需要权限', + connectionRequired: '需要连接', + signInRequired: '需要重新登录', + stopped: '已中止', + }, + en: { + running: 'running', + waitingForUser: 'waiting for you', + permissionRequired: 'needs permission', + connectionRequired: 'needs connection', + signInRequired: 'needs sign-in', + stopped: 'stopped', + }, +} satisfies UiCatalog; + +/** + * Present the runtime Session state as compact picker copy. + * + * Persisted `running` is only credible when the Runtime Host also reports a + * live Turn. Non-actionable blocked reasons remain ordinary resumable rows, + * matching Desktop's display projection rather than exposing bookkeeping + * noise as a broken Session. + */ +export function sessionStatusBadge( + session: Pick, + locale: UiLocale, +): string | undefined { + const copy = TUI_SESSION_STATUS_COPY[locale]; + switch (session.status) { + case 'active': + return undefined; + case 'running': + return session.runningTurnIds?.length ? copy.running : undefined; + case 'waiting_for_user': + return session.blockedReason === 'permission_required' + ? copy.permissionRequired + : copy.waitingForUser; + case 'blocked': + switch (session.blockedReason) { + case 'NO_REAL_CONNECTION': + return copy.connectionRequired; + case 'auth': + return copy.signInRequired; + case 'permission_required': + return copy.permissionRequired; + case 'tool_failed': + case 'unknown': + case undefined: + return undefined; + } + case 'aborted': + return copy.stopped; + } +}