fix(cli): preserve OpenTUI extension update state (#15716)

* fix(cli): align OpenTUI cancellation and retry with Ink

* fix(cli): discard abandoned OpenTUI tool attempts

* feat(cli): preserve OpenTUI session replay and rewind

* fix(cli): preserve checkpoint restore ownership and cold continuation

* test(cli): cover committed conversation rewind across renderers

* feat(cli): capture and restore OpenTUI checkpoints

Candidate checkpoint before parent test integration; full normal and combined product validation are still pending.

* test(cli): remove duplicate checkpoint regression declarations

* fix(cli): preserve Ink compact tool summaries after restore

* fix(cli): preserve OpenTUI clipboard source through draft recovery

* test(cli): cover OpenTUI raw recall and framed Vim submissions

* fix(ci): register clipboard regression in TUI parity gate

* feat(cli): complete OpenTUI extension discovery and source management

* fix(cli): consolidate catalog interactions and focused key subscriptions

* fix(cli): release catalog keyboard subscriptions during consent

* fix(cli): preserve complete OpenTUI source information

* test(cli): wait for restored assistant before recovery assertions

* fix(test): preserve runner Git in installed CLI isolation

* fix(cli): preserve OpenTUI extension update state
This commit is contained in:
ChiGao authored and GitHub committed 2026-10-27 20:09:27 +08:00
1 parent 36ab56dd36
commit 341d6626a4
4 files changed
+103 -13

No files matched your search

@@ -421,6 +421,29 @@ describe('OpenTuiExtensionsDialog management keys (#44)', () => {
expect(screen.getByText('Update Now')).toBeTruthy();
});
it.each(['up-to-date', undefined] as const)(
'adopts a settled update state: %s',
async (state) => {
const onDetailAction = vi
.fn()
.mockResolvedValueOnce('update-available')
.mockResolvedValueOnce(state);
renderDialog({ onDetailAction });
await press('return');
await press('down');
await press('down');
await press('down');
await press('return');
expect(screen.getByText('Update Now')).toBeTruthy();
await press('down');
await press('return');
expect(onDetailAction).toHaveBeenLastCalledWith(ROWS[0], 'update');
expect(screen.queryByText('Update Now') !== null).toBe(
state === undefined,
);
},
);
it('falls back to the list when the open row disappears after a reload', async () => {
const { rerender } = render(<div />);
const onClose = vi.fn();
@@ -383,8 +383,8 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) {
setView('uninstall-confirm');
return;
}
if (action === 'mark-update') {
void Promise.resolve(onDetailAction?.(currentRow, 'mark-update'))
if (action === 'mark-update' || action === 'update') {
void Promise.resolve(onDetailAction?.(currentRow, action))
.then((state) => {
if (state) setCheckedUpdateState(state);
})
@@ -33,6 +33,8 @@ import { ExtensionRefreshState } from '../../config/extension-refresh-state.js';
import { AppEvent } from '../../utils/events.js';
import {
addWorkspaceDirectory,
applyExtensionUpdate,
applyExtensionUpdateCheck,
applyModelSelection,
applyMcpServerAction,
applyThemeSelection,
@@ -152,8 +154,8 @@ vi.mock('./dialog-data.js', () => ({
applyExtensionFavorite: () => {},
applyExtensionUninstall: async () => {},
applyExtensionScopeChange: async () => {},
applyExtensionUpdate: async () => {},
applyExtensionUpdateCheck: async () => null,
applyExtensionUpdate: vi.fn(async () => {}),
applyExtensionUpdateCheck: vi.fn(async () => null),
buildModelEntries: () => [],
computeModelDialogInitialKey: () => undefined,
applyModelSelection: vi.fn(async () => ({ ok: true as const })),
@@ -321,6 +323,66 @@ describe('OpenTuiDialogMount routing', () => {
mockGetHookSystem.mockReset();
});
it.each([
'update-available',
'up-to-date',
'not-updatable',
'error',
] as const)('returns the extension update check state: %s', async (state) => {
const message = `Update check result: ${state}`;
vi.mocked(applyExtensionUpdateCheck).mockResolvedValueOnce({
message,
state,
});
mount({ dialog: 'extensions_manage' });
let result: unknown;
await act(async () => {
result = await dialogProp('extensions_manage', 'onDetailAction')(
{ key: 'owned-extension' },
'mark-update',
);
});
expect(applyExtensionUpdateCheck).toHaveBeenCalledWith(
CONFIG,
'owned-extension',
);
expect(result).toBe(state);
expect(mocks.state.dialogProps['extensions_manage']['status']).toEqual({
type: state === 'error' ? 'error' : 'info',
text: message,
});
expect(mocks.state.dialogProps['extensions_manage']['busy']).toBe(false);
});
it.each([true, false])(
'settles an extension update with changed=%s',
async (changed) => {
vi.mocked(applyExtensionUpdate).mockResolvedValueOnce({
message: 'Update result',
changed,
level: changed ? 'success' : 'error',
});
mount({ dialog: 'extensions_manage' });
let state: unknown;
await act(async () => {
state = await dialogProp('extensions_manage', 'onDetailAction')(
{ key: 'owned-extension' },
'update',
);
});
expect(applyExtensionUpdate).toHaveBeenCalledWith(
CONFIG,
'owned-extension',
);
expect(state).toBe(changed ? 'up-to-date' : undefined);
expect(mocks.state.dialogProps['extensions_manage']['busy']).toBe(false);
expect(mocks.state.dialogProps['extensions_manage']['status']).toEqual({
type: changed ? 'success' : 'error',
text: 'Update result',
});
},
);
it('routes every dialog request to its own component', () => {
expect(REQUESTS).toHaveLength(25);
for (const [expected, request] of REQUESTS) {
@@ -283,19 +283,25 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
).then(settle);
break;
case 'update':
void applyExtensionUpdate(config, name).then(settle);
break;
return applyExtensionUpdate(config, name).then((result) => {
settle(result);
return result.changed ? ('up-to-date' as const) : undefined;
});
case 'mark-update':
void applyExtensionUpdateCheck(config, name).then((state) => {
return applyExtensionUpdateCheck(config, name).then((result) => {
setExtBusy(false);
if (state) setExtStatus({ type: 'info', text: 'Update available.' });
setExtStatus({
type: result.state === 'error' ? 'error' : 'info',
text: result.message,
});
return result.state;
});
break;
default: {
const exhaustive: never = action;
void exhaustive;
}
}
return undefined;
};
// --- model dialog error (kept open on failed selection) -----------------
@@ -600,10 +606,9 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
onRowAction={(row, action) =>
runExtensionAction(row.key, action as ExtensionDetailAction)
}
onDetailAction={(row, action, arg) => {
runExtensionAction(row.key, action, arg);
return undefined;
}}
onDetailAction={(row, action, arg) =>
runExtensionAction(row.key, action, arg)
}
/>
);
}